Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a0d502be | ||
|
|
2afd4aaf83 | ||
|
|
e5dd6af366 | ||
|
|
9436eab129 | ||
|
|
1408bddf58 | ||
|
|
e02ab51ee1 | ||
|
|
3a37a5f90f | ||
|
|
e7417d16a6 | ||
|
|
24d3bf83bb | ||
|
|
af052dcb4f | ||
|
|
b12268e740 | ||
|
|
6ee3bbe67c | ||
|
|
d8f6c25875 | ||
|
|
2155c94161 | ||
|
|
87f3e591ce | ||
|
|
1e903232f0 | ||
|
|
cebba87864 | ||
|
|
7eabaf2542 | ||
|
|
9beae3df28 |
+7
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||||
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||||
|
// Interest Payable is editable on cash too, and optional. Interest posting routes
|
||||||
|
// through it on either basis, so a product can name one to carry interest declared
|
||||||
|
// but not yet credited; a product that names none still posts straight to Savings
|
||||||
|
// Control. Created-or-merged because a product configured before this row existed
|
||||||
|
// has no mapping to merge into.
|
||||||
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
|
||||||
|
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_PERIODIC:
|
case ACCRUAL_PERIODIC:
|
||||||
// asset
|
// asset
|
||||||
|
|||||||
+10
-20
@@ -137,7 +137,16 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
// isApprovedByChecker is only ever true here for a genuine checker approval
|
||||||
|
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
|
||||||
|
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
|
||||||
|
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
|
||||||
|
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
|
||||||
|
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
|
||||||
|
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
|
||||||
|
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
|
||||||
|
// auto-approve their own submission just by holding it.
|
||||||
|
if (isApprovedByChecker) {
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -151,25 +160,6 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
|
||||||
if (!user.isCheckerSuperUser()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+111
@@ -756,6 +756,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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() {
|
public CommandWrapperBuilder createReport() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "REPORT";
|
this.entityName = "REPORT";
|
||||||
@@ -884,6 +892,70 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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() {
|
public CommandWrapperBuilder createLoanProduct() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "LOANPRODUCT";
|
this.entityName = "LOANPRODUCT";
|
||||||
@@ -3390,6 +3462,13 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateCurrency() {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "CURRENCY";
|
||||||
|
this.href = "/currencies";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
|
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "EXTERNALSERVICES";
|
this.entityName = "EXTERNALSERVICES";
|
||||||
@@ -4730,4 +4809,36 @@ public class CommandWrapperBuilder {
|
|||||||
this.href = "/loans/" + loanId + "/originators/" + originatorId;
|
this.href = "/loans/" + loanId + "/originators/" + originatorId;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createSurvey() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/surveys";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "ACTIVATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "DEACTIVATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-8
@@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final CommandProcessingService processAndLogCommandService;
|
private final CommandProcessingService processAndLogCommandService;
|
||||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
||||||
|
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
|
||||||
|
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
|
||||||
|
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
|
||||||
|
// live, independently-configurable global switch (System -> Configurations); deleting this field,
|
||||||
|
// and by extension deciding that switch should stop existing altogether, is a separate decision
|
||||||
|
// from closing this specific self-approval loophole.
|
||||||
private final ConfigurationDomainService configurationService;
|
private final ConfigurationDomainService configurationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
AppUser appUser = this.context.authenticatedUser();
|
AppUser appUser = this.context.authenticatedUser();
|
||||||
String permissionCode = commandSource.getPermissionCode();
|
String permissionCode = commandSource.getPermissionCode();
|
||||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
||||||
AppUser maker = commandSource.getMaker();
|
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
||||||
if (maker == null) {
|
// permission still lets its holder action a DIFFERENT user's request without needing the
|
||||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
|
||||||
}
|
// longer lets them action their own) and not overridable by the enable-same-maker-checker
|
||||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
||||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
// configuration is left in place and unused for this decision rather than removed, since
|
||||||
}
|
// deleting a still-configurable global setting is a separate decision from this one).
|
||||||
|
AppUser maker = commandSource.getMaker();
|
||||||
|
if (maker == null) {
|
||||||
|
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||||
|
}
|
||||||
|
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||||
|
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||||
}
|
}
|
||||||
return commandSource;
|
return commandSource;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -73,6 +73,7 @@ public enum JobName {
|
|||||||
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
||||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||||
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||||
|
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
||||||
; //
|
; //
|
||||||
|
|
||||||
private final String name;
|
private final String name;
|
||||||
|
|||||||
+12
-11
@@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT;
|
|||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.function.Supplier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.command.core.CommandPipeline;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand;
|
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.CurrencyConfigurationData;
|
||||||
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
|
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.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ import org.springframework.stereotype.Component;
|
|||||||
public class CurrenciesApiResource {
|
public class CurrenciesApiResource {
|
||||||
|
|
||||||
private final OrganisationCurrencyReadPlatformService readPlatformService;
|
private final OrganisationCurrencyReadPlatformService readPlatformService;
|
||||||
private final CommandPipeline commandPipeline;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<CurrencyUpdateRequest> toApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -65,13 +67,12 @@ public class CurrenciesApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
|
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
|
||||||
public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) {
|
public String updateCurrencies(@Valid final CurrencyUpdateRequest request) {
|
||||||
final var command = new CurrencyUpdateCommand();
|
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 toApiJsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -55,17 +55,25 @@ public enum PortfolioProductType {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matched against each constant's own declared value above, not its declaration position --
|
||||||
|
// those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written
|
||||||
|
// by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING)
|
||||||
|
// returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES,
|
||||||
|
// and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry
|
||||||
|
// (entity_type_enum=6) reached this on the Journal Entries screen.
|
||||||
switch (v) {
|
switch (v) {
|
||||||
case 1:
|
case 1:
|
||||||
return LOAN;
|
return LOAN;
|
||||||
case 2:
|
case 2:
|
||||||
return SAVING;
|
return SAVING;
|
||||||
case 3:
|
case 3:
|
||||||
return CLIENT;
|
|
||||||
case 4:
|
|
||||||
return PROVISIONING;
|
return PROVISIONING;
|
||||||
case 5:
|
case 4:
|
||||||
return SHARES;
|
return SHARES;
|
||||||
|
case 5:
|
||||||
|
return CLIENT;
|
||||||
|
case 6:
|
||||||
|
return CURRENT;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-29
@@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Supplier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
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.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.PaymentTypeCreateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
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.PaymentTypeUpdateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -55,7 +50,7 @@ import org.springframework.stereotype.Component;
|
|||||||
public class PaymentTypeApiResource {
|
public class PaymentTypeApiResource {
|
||||||
|
|
||||||
private final PaymentTypeReadService readPlatformService;
|
private final PaymentTypeReadService readPlatformService;
|
||||||
private final CommandPipeline commandPipeline;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
|
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -79,14 +74,13 @@ public class PaymentTypeApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
|
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
|
||||||
public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) {
|
public String createPaymentType(final PaymentTypeCreateRequest request) {
|
||||||
final var command = new PaymentTypeCreateCommand();
|
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 jsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@@ -94,18 +88,16 @@ public class PaymentTypeApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
|
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
|
||||||
public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId,
|
public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) {
|
||||||
final PaymentTypeUpdateRequest request) {
|
|
||||||
|
|
||||||
request.setId(paymentTypeId);
|
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 jsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@@ -113,14 +105,12 @@ public class PaymentTypeApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Delete a Payment Type", description = "Deletes payment type")
|
@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 jsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-17
@@ -18,33 +18,50 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.paymenttype.handler;
|
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
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;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
|
||||||
@RequiredArgsConstructor
|
public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler {
|
||||||
public class PaymentTypeCreateCommandHandler implements CommandHandler<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
|
|
||||||
|
|
||||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||||
|
|
||||||
@Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback")
|
@Autowired
|
||||||
@Override
|
public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||||
@Transactional
|
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||||
public PaymentTypeCreateResponse handle(Command<PaymentTypeCreateRequest> command) {
|
|
||||||
return paymentTypeWriteService.createPaymentType(command.getPayload());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public PaymentTypeCreateResponse fallback(Command<PaymentTypeCreateRequest> command, Throwable t) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return CommandHandler.super.fallback(command, t);
|
|
||||||
|
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;
|
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
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;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "PAYMENTTYPE", action = "DELETE")
|
||||||
@RequiredArgsConstructor
|
public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler {
|
||||||
public class PaymentTypeDeleteCommandHandler implements CommandHandler<PaymentTypeDeleteRequest, PaymentTypeDeleteResponse> {
|
|
||||||
|
|
||||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||||
|
|
||||||
@Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback")
|
@Autowired
|
||||||
@Override
|
public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||||
@Transactional
|
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||||
public PaymentTypeDeleteResponse handle(Command<PaymentTypeDeleteRequest> command) {
|
|
||||||
return paymentTypeWriteService.deletePaymentType(command.getPayload());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public PaymentTypeDeleteResponse fallback(Command<PaymentTypeDeleteRequest> command, Throwable t) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return CommandHandler.super.fallback(command, t);
|
|
||||||
|
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;
|
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
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;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "PAYMENTTYPE", action = "UPDATE")
|
||||||
@RequiredArgsConstructor
|
public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler {
|
||||||
public class PaymentTypeUpdateCommandHandler implements CommandHandler<PaymentTypeUpdateRequest, PaymentTypeUpdateResponse> {
|
|
||||||
|
|
||||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||||
|
|
||||||
@Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback")
|
@Autowired
|
||||||
@Override
|
public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||||
@Transactional
|
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||||
public PaymentTypeUpdateResponse handle(Command<PaymentTypeUpdateRequest> command) {
|
|
||||||
return paymentTypeWriteService.updatePaymentType(command.getPayload());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public PaymentTypeUpdateResponse fallback(Command<PaymentTypeUpdateRequest> command, Throwable t) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return CommandHandler.super.fallback(command, t);
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-11
@@ -105,17 +105,22 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
||||||
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
||||||
.append(" eao.external_id as externalAssetOwner, ")
|
.append(" eao.external_id as externalAssetOwner, ")
|
||||||
// entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and
|
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
|
||||||
// current-account entries -- current products deliberately share SAVING's GL type
|
// m_current_account -- newer write paths tag current-account entries with it
|
||||||
// space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two
|
// instead of reusing SAVING's type, specifically so this case needs no guessing.
|
||||||
// apart on its own, and m_savings_account/m_current_account each have their own id
|
//
|
||||||
// sequence: an entity_id can be a real row in both, making a plain
|
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
|
||||||
// COALESCE(ca.account_no, sa.account_no) pick the current account's number even for
|
// and current-account entries by older write paths not yet updated to the above --
|
||||||
// an entry that is genuinely a savings one. savings_transaction_id is the one
|
// current products used to deliberately share SAVING's GL type space (see
|
||||||
// unambiguous signal available here -- only a real savings transaction sets it -- so
|
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
|
||||||
// it decides first; only when it's absent (a current-account entry, or a savings
|
// m_current_account each have their own id sequence, so an entity_id can be a real
|
||||||
// entry with no linked transaction row) does the current-account guess apply.
|
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
|
||||||
|
// account's number even for an entry that is genuinely a savings one.
|
||||||
|
// savings_transaction_id is the one unambiguous signal available for those older
|
||||||
|
// entries -- only a real savings transaction sets it -- so it decides first; only
|
||||||
|
// when it's absent does the current-account guess apply.
|
||||||
.append(" CASE ")
|
.append(" CASE ")
|
||||||
|
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
|
||||||
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
|
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
|
||||||
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
|
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
|
||||||
.append(" ELSE NULL ")
|
.append(" ELSE NULL ")
|
||||||
@@ -141,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
|
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
|
||||||
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
|
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
|
||||||
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
|
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
|
||||||
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
|
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
|
||||||
if (associationParametersData.isTransactionDetailsRequired()) {
|
if (associationParametersData.isTransactionDetailsRequired()) {
|
||||||
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
|
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
|
||||||
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
|
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
|
||||||
|
|||||||
+12
@@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
|||||||
break;
|
break;
|
||||||
case CASH_BASED:
|
case CASH_BASED:
|
||||||
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
|
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
|
||||||
|
// Interest Payable is saved on cash too, and optionally: interest posting routes
|
||||||
|
// through it on either basis (see AccountingProcessorHelper
|
||||||
|
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
|
||||||
|
// yet credited to the member reads as the bank's own liability rather than appearing
|
||||||
|
// only at the moment it is paid. Mapped only when the screen supplies it - a product
|
||||||
|
// naming none still posts straight to Savings Control, as it always did.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
|
||||||
|
element)) {
|
||||||
|
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
|
||||||
|
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
|
||||||
|
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ACCRUAL_PERIODIC:
|
case ACCRUAL_PERIODIC:
|
||||||
|
|||||||
+3
@@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO;
|
|||||||
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
|
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
|
||||||
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
|
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
|
||||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/loans")
|
@Path("/v1/loans")
|
||||||
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
|
|||||||
public class LoanCOBCatchUpApiResource {
|
public class LoanCOBCatchUpApiResource {
|
||||||
|
|
||||||
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
|
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("oldest-cob-closed")
|
@Path("oldest-cob-closed")
|
||||||
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
|
|||||||
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
|
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
|
||||||
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
|
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
|
||||||
public Response executeLoanCOBCatchUp() {
|
public Response executeLoanCOBCatchUp() {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
|
||||||
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
|
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
|
||||||
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
|
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
|
||||||
return Response.status(Response.Status.BAD_REQUEST).build();
|
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||||
|
|||||||
+49
-8
@@ -165,9 +165,11 @@ public class SecurityConfig {
|
|||||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||||
// Client account summary, third-party callers — a partner integration calls this
|
// 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
|
// with an X-Api-Key header instead of a Fineract username/password. The key itself
|
||||||
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
|
// is issued by inserting directly into m_api_client in the tenant-store database
|
||||||
// outer gate stays open for this one narrow GET path, and
|
// (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
|
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
|
||||||
// the caller (ApiClientCredentialService checks the key's hash, active flag and
|
// 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,
|
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
|
||||||
@@ -222,7 +224,10 @@ public class SecurityConfig {
|
|||||||
// currency
|
// currency
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY")
|
.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")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY")
|
||||||
// notes: read
|
// notes: read
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
|
||||||
@@ -355,11 +360,18 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
|
||||||
// payment type
|
// 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/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
|
.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")
|
.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")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE")
|
||||||
// mix: taxonomy
|
// mix: taxonomy
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
|
||||||
@@ -390,12 +402,41 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
|
||||||
// working days
|
// 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"))
|
.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"))
|
.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"))
|
.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(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
|
||||||
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
|
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
|
||||||
|
|||||||
+20
-14
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
|
|||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DELETE;
|
import jakarta.ws.rs.DELETE;
|
||||||
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
|
* 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
|
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
|
||||||
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
|
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
|
||||||
* 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.
|
* {@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")
|
@Path("/v1/apiclients")
|
||||||
@Component
|
@Component
|
||||||
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final ApiClientCredentialService credentialService;
|
private final ApiClientCredentialService credentialService;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
|
||||||
@POST
|
@POST
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String create(final String body) {
|
public String create(final String body) {
|
||||||
final AppUser user = requireSuperUser();
|
requireSuperUser();
|
||||||
final JsonElement root = fromJsonHelper.parse(body);
|
throw new WebApplicationException(Response.status(403)
|
||||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
|
||||||
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
|
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
|
||||||
|
.type(MediaType.APPLICATION_JSON).build());
|
||||||
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
|
|
||||||
return gson.toJson(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
|
|||||||
+12
-15
@@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT;
|
|||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.command.core.CommandPipeline;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand;
|
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.WorkingDaysData;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
|
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator;
|
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.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -50,7 +50,8 @@ public class WorkingDaysApiResource {
|
|||||||
|
|
||||||
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
|
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
|
||||||
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
|
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
|
||||||
private final CommandPipeline commandPipeline;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<WorkingDaysUpdateRequest> toApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -65,17 +66,13 @@ public class WorkingDaysApiResource {
|
|||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
|
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
|
||||||
+ "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale")
|
+ "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());
|
return toApiJsonSerializer.serialize(result);
|
||||||
command.setCreatedAt(Instant.now());
|
|
||||||
command.setPayload(request);
|
|
||||||
|
|
||||||
final Supplier<WorkingDaysUpdateResponse> response = commandPipeline.send(command);
|
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
|
|||||||
+29
-21
@@ -18,38 +18,46 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.organisation.workingdays.handler;
|
package org.apache.fineract.organisation.workingdays.handler;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
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.WorkingDaysUpdateRequest;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
|
|
||||||
import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService;
|
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;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "WORKINGDAYS", action = "UPDATE")
|
||||||
@RequiredArgsConstructor
|
public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler {
|
||||||
public class UpdateWorkingDaysCommandHandler implements CommandHandler<WorkingDaysUpdateRequest, WorkingDaysUpdateResponse> {
|
|
||||||
|
|
||||||
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
|
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) {
|
||||||
|
this.workingDaysWritePlatformService = workingDaysWritePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public WorkingDaysUpdateResponse handle(Command<WorkingDaysUpdateRequest> command) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
WorkingDaysUpdateRequest request = command.getPayload();
|
|
||||||
Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
|
|
||||||
if (changes == null) {
|
|
||||||
changes = Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes)
|
final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder()
|
||||||
.recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType())
|
.recurrence(command.stringValueOfParameterNamed("recurrence"))
|
||||||
.extendTermForDailyRepayments(request.getExtendTermForDailyRepayments())
|
.repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType"))
|
||||||
.extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build();
|
.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();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
||||||
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
||||||
*/
|
*/
|
||||||
|
// m_savings_account and m_current_account each keep their own identity sequence, so the same
|
||||||
|
// entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to
|
||||||
|
// be written for both savings and current-account entries alike, so esa/eca could both match the
|
||||||
|
// same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever
|
||||||
|
// client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of
|
||||||
|
// misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT)
|
||||||
|
// is what newer write paths tag current-account entries with instead, unambiguously; for older
|
||||||
|
// entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine
|
||||||
|
// savings transaction sets it), checked before the ambiguous current/savings guess.
|
||||||
private static final String RESOLVE_SQL = """
|
private static final String RESOLVE_SQL = """
|
||||||
SELECT je.amount AS amount,
|
SELECT je.amount AS amount,
|
||||||
je.currency_code AS currencyCode,
|
je.currency_code AS currencyCode,
|
||||||
@@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
je.loan_transaction_id AS loanTxnId,
|
je.loan_transaction_id AS loanTxnId,
|
||||||
je.entity_type_enum AS entityType,
|
je.entity_type_enum AS entityType,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
esa.client_id,
|
CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END,
|
||||||
eca.client_id,
|
CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END,
|
||||||
|
CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END,
|
||||||
eloan.client_id,
|
eloan.client_id,
|
||||||
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
||||||
sa.client_id,
|
sa.client_id,
|
||||||
@@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
) AS clientId
|
) AS clientId
|
||||||
FROM acc_gl_journal_entry je
|
FROM acc_gl_journal_entry je
|
||||||
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
||||||
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6)
|
||||||
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
||||||
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
||||||
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
||||||
|
|||||||
+2
-2
@@ -200,7 +200,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||||
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||||
AND (std.officer_id IS NULL
|
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
|
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)
|
WHERE au.id = std.officer_id)
|
||||||
@@ -211,7 +211,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||||
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||||
AND (std.officer_id IS NULL
|
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
|
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)
|
WHERE au.id = std.officer_id)
|
||||||
|
|||||||
+23
-3
@@ -192,7 +192,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
// reject() will delete these records so the vault position is reversed.
|
// reject() will delete these records so the vault position is reversed.
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
||||||
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
|
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||||
@@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource {
|
|||||||
updatedLog, Timestamp.from(Instant.now()), id
|
updatedLog, Timestamp.from(Instant.now()), id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Rejection deleted this request's notes (the cash went back to the customer). Resubmitting
|
||||||
|
// means the cashier took the cash again, so the notes are recorded again -- without this an
|
||||||
|
// approved resubmission credited the account while the drawer never received its notes.
|
||||||
|
// Cleared first so a resubmit can never leave the old breakdown alongside the new one.
|
||||||
|
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
||||||
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
|
savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")),
|
||||||
|
(String) record.get("account_no"), toLong(record.get("client_id")),
|
||||||
|
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the central voucher register in sync with the edited amount
|
// Keep the central voucher register in sync with the edited amount
|
||||||
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
|
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
|
||||||
|
|
||||||
@@ -1004,9 +1015,14 @@ public class CashierDepositRequestApiResource {
|
|||||||
*/
|
*/
|
||||||
// Called at submit time — uses request id as temporary txn_id.
|
// Called at submit time — uses request id as temporary txn_id.
|
||||||
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
||||||
|
private static Long toLong(final Object value) {
|
||||||
|
return value instanceof Number n ? n.longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private void savePendingDenomination(final String denominationData,
|
private void savePendingDenomination(final String denominationData,
|
||||||
final Long requestId, final Long savingsAccountId, final String accountNo,
|
final Long requestId, final Long savingsAccountId, final String accountNo,
|
||||||
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
|
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName,
|
||||||
|
final String txnDate) {
|
||||||
final JsonObject denomMap;
|
final JsonObject denomMap;
|
||||||
try {
|
try {
|
||||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
@@ -1037,10 +1053,14 @@ public class CashierDepositRequestApiResource {
|
|||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
|
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
|
// Dated on the request's own date, which is what the branch day's book figure reads.
|
||||||
|
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
|
||||||
|
// drawer on another and leave both days' cash wrong.
|
||||||
|
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
|
||||||
cashierId, cashierUsername, paymentTypeName
|
cashierId, cashierUsername, paymentTypeName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -90,7 +90,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'DEPOSIT' "
|
+ "WHERE std.txn_type = 'DEPOSIT' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " 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 = ?) "
|
||||||
@@ -120,7 +120,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " 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 = ?) "
|
||||||
|
|||||||
+4
-27
@@ -60,34 +60,11 @@ public class CashReportApiResource {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
|
* The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW},
|
||||||
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
|
* shared with the day register, the reconciliation screen and the withdrawal stock check, so a
|
||||||
* reads the same on all four.
|
* drawer reads the same on all four.
|
||||||
*/
|
*/
|
||||||
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW;
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
|
||||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
|
|
||||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
|
||||||
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
|
|
||||||
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
|
|
||||||
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
|
||||||
// such transactions in this database were carrying ₹1.68 lakh that never existed.
|
|
||||||
//
|
|
||||||
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
|
|
||||||
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
|
|
||||||
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
|
|
||||||
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
|
|
||||||
// everything counted after it) out of the cashier's position.
|
|
||||||
//
|
|
||||||
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
|
||||||
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
|
|
||||||
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
|
|
||||||
// CountedCashLedger's rule above.
|
|
||||||
+ "AND NOT EXISTS ("
|
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
|
||||||
+ " WHERE mt.id = std.txn_id"
|
|
||||||
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
|
|
||||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Day book — every voucher raised on one date, in one list, newest first.
|
* Day book — every voucher raised on one date, in one list, newest first.
|
||||||
|
|||||||
+21
-7
@@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||||
import org.apache.fineract.organisation.office.domain.Office;
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||||
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
|
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
|
||||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||||
@@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
||||||
accountId);
|
accountId, accountType);
|
||||||
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
||||||
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
||||||
chequeBookId);
|
chequeBookId);
|
||||||
@@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
|
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
|
||||||
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||||
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) {
|
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
|
||||||
|
final ChequeAccountType accountType) {
|
||||||
final JsonArray debitArr = new JsonArray();
|
final JsonArray debitArr = new JsonArray();
|
||||||
final JsonObject debit = new JsonObject();
|
final JsonObject debit = new JsonObject();
|
||||||
debit.addProperty("glAccountId", debitGlId);
|
debit.addProperty("glAccountId", debitGlId);
|
||||||
@@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
// link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
|
// link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
|
||||||
final String glTxnId = result.getTransactionId();
|
final String glTxnId = result.getTransactionId();
|
||||||
if (glTxnId != null) {
|
if (glTxnId != null) {
|
||||||
linkDebitEntryToAccount(glTxnId, accountId);
|
linkDebitEntryToAccount(glTxnId, accountId, accountType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
|
/**
|
||||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
* Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an
|
||||||
|
* Account No.
|
||||||
|
*
|
||||||
|
* m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can
|
||||||
|
* be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of
|
||||||
|
* accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings
|
||||||
|
* account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually
|
||||||
|
* means "this id is in m_current_account" -- use it whenever accountType says so.
|
||||||
|
*/
|
||||||
|
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) {
|
||||||
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
||||||
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
||||||
// stale, unrelated journal entry.
|
// stale, unrelated journal entry.
|
||||||
@@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
||||||
glTxnId);
|
glTxnId);
|
||||||
if (!rowIds.isEmpty()) {
|
if (!rowIds.isEmpty()) {
|
||||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
|
final int entityType = accountType == ChequeAccountType.CURRENT
|
||||||
rowIds.get(0));
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
|
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
|
||||||
|
accountId, rowIds.get(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -118,7 +119,7 @@ public class ClearingJournalService {
|
|||||||
}
|
}
|
||||||
final String glTxnId = result.getTransactionId();
|
final String glTxnId = result.getTransactionId();
|
||||||
if (glTxnId != null && linkAccountId != null) {
|
if (glTxnId != null && linkAccountId != null) {
|
||||||
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType);
|
||||||
}
|
}
|
||||||
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
||||||
return glTxnId;
|
return glTxnId;
|
||||||
@@ -191,7 +192,13 @@ public class ClearingJournalService {
|
|||||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
/**
|
||||||
|
* Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two
|
||||||
|
* account tables number independently, so entity_type_enum must say which one accountId names, not
|
||||||
|
* assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means
|
||||||
|
* "this id is in m_current_account".
|
||||||
|
*/
|
||||||
|
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) {
|
||||||
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
||||||
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
||||||
// stale, unrelated journal entry.
|
// stale, unrelated journal entry.
|
||||||
@@ -200,8 +207,11 @@ public class ClearingJournalService {
|
|||||||
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
||||||
glTxnId);
|
glTxnId);
|
||||||
if (!rowIds.isEmpty()) {
|
if (!rowIds.isEmpty()) {
|
||||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
|
final int entityType = "CURRENT".equalsIgnoreCase(accountType)
|
||||||
rowIds.get(0));
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
|
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
|
||||||
|
accountId, rowIds.get(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-14
@@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
|
|||||||
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
@@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
||||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
|
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
||||||
btTotalDr = btTotalDr.add(entryAmt);
|
btTotalDr = btTotalDr.add(entryAmt);
|
||||||
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
||||||
@@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
|
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
||||||
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
||||||
}
|
}
|
||||||
@@ -630,7 +631,15 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return ps;
|
return ps;
|
||||||
}, btLogKh);
|
}, btLogKh);
|
||||||
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
|
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
|
||||||
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
|
// submit() already raised this request's voucher (every request type does, so it
|
||||||
|
// shows in My Vouchers the moment a clerk submits it, before any checker acts) --
|
||||||
|
// minting a second one here for Bank Transfer specifically double-counted the same
|
||||||
|
// transfer under two different names, "Entered by" whoever raised it AND whoever
|
||||||
|
// approved it. Reuse that voucher; only fall back to raising a fresh one if this
|
||||||
|
// request somehow reached approval without it (defensive, not the normal path).
|
||||||
|
final Object existingVoucherNo = record.get("voucher_no");
|
||||||
|
final String btVoucherNo = existingVoucherNo != null ? (String) existingVoucherNo
|
||||||
|
: generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
|
||||||
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
|
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +690,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(directCaTxnId));
|
txnRefs.add(String.valueOf(directCaTxnId));
|
||||||
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
||||||
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
|
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true));
|
||||||
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
|
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
|
||||||
} else {
|
} else {
|
||||||
// Real CBS book transfer: direct credit to savings account.
|
// Real CBS book transfer: direct credit to savings account.
|
||||||
@@ -696,7 +705,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
accountId, entryAmt, officeId, txnDateStr, currentUser);
|
accountId, entryAmt, officeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(directTxnId));
|
txnRefs.add(String.valueOf(directTxnId));
|
||||||
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
||||||
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
|
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false));
|
||||||
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
|
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -715,7 +724,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(debitTxnId));
|
txnRefs.add(String.valueOf(debitTxnId));
|
||||||
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
||||||
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
|
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount));
|
||||||
if (isCurrentAccount) {
|
if (isCurrentAccount) {
|
||||||
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
|
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -979,7 +988,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
|
||||||
|
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
|
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1005,7 +1014,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
|
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1568,20 +1577,27 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final BigDecimal amount;
|
final BigDecimal amount;
|
||||||
final String accountRef; // savings account number shown in GL comments; null for source/target GL
|
final String accountRef; // savings account number shown in GL comments; null for source/target GL
|
||||||
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
||||||
final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries
|
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
|
||||||
|
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
|
||||||
|
|
||||||
GlEntry(final Long glAccountId, final BigDecimal amount,
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
final String accountRef, final String txnId) {
|
final String accountRef, final String txnId) {
|
||||||
this(glAccountId, amount, accountRef, txnId, null);
|
this(glAccountId, amount, accountRef, txnId, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
GlEntry(final Long glAccountId, final BigDecimal amount,
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
final String accountRef, final String txnId, final Long savingsAccountId) {
|
final String accountRef, final String txnId, final Long savingsAccountId) {
|
||||||
|
this(glAccountId, amount, accountRef, txnId, savingsAccountId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId,
|
||||||
|
final Long savingsAccountId, final boolean isCurrentAccount) {
|
||||||
this.glAccountId = glAccountId;
|
this.glAccountId = glAccountId;
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
this.accountRef = accountRef;
|
this.accountRef = accountRef;
|
||||||
this.txnId = txnId;
|
this.txnId = txnId;
|
||||||
this.savingsAccountId = savingsAccountId;
|
this.savingsAccountId = savingsAccountId;
|
||||||
|
this.isCurrentAccount = isCurrentAccount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1603,8 +1619,16 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
|
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
|
||||||
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
|
* row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—".
|
||||||
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
|
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
|
||||||
|
*
|
||||||
|
* m_current_account and m_savings_account each keep their own identity sequence, so the same
|
||||||
|
* numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2
|
||||||
|
* (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view
|
||||||
|
* would resolve a current account's entry to a same-numbered savings account or vice versa.
|
||||||
|
* entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's
|
||||||
|
* entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one
|
||||||
|
* this entry's id actually is, decided once already, where the account was first resolved.
|
||||||
*/
|
*/
|
||||||
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
|
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
|
||||||
final List<GlEntry> entries, final int typeEnum) {
|
final List<GlEntry> entries, final int typeEnum) {
|
||||||
@@ -1618,11 +1642,15 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
Long.class, glTxnId, typeEnum, entries.size());
|
Long.class, glTxnId, typeEnum, entries.size());
|
||||||
java.util.Collections.reverse(rowIds);
|
java.util.Collections.reverse(rowIds);
|
||||||
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
|
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
|
||||||
final Long savingsAccId = entries.get(i).savingsAccountId;
|
final GlEntry entry = entries.get(i);
|
||||||
|
final Long savingsAccId = entry.savingsAccountId;
|
||||||
if (savingsAccId != null) {
|
if (savingsAccId != null) {
|
||||||
|
final int entityType = entry.isCurrentAccount
|
||||||
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?",
|
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?",
|
||||||
savingsAccId, rowIds.get(i));
|
entityType, savingsAccId, rowIds.get(i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -722,10 +722,13 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'WITHDRAWAL', ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
txnId, savingsAccountId, accountNo, clientId, denomId,
|
txnId, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
|
// The request's own date, which the branch day's paid-out figure reads -- not the
|
||||||
|
// database clock, which put the notes on a different day from the withdrawal.
|
||||||
|
record.get("txn_date"),
|
||||||
cashier.getId(), cashier.getUsername(), paymentTypeName
|
cashier.getId(), cashier.getUsername(), paymentTypeName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -87,4 +87,53 @@ public final class CountedCashLedger {
|
|||||||
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
|
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
|
||||||
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
|
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
|
||||||
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
|
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link #REAL_TXN_ID} plus the two tests a real id can still fail, as one predicate over the alias
|
||||||
|
* {@code std}. Every drawer figure reads this, not {@code REAL_TXN_ID} alone.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Only the cashier position report used to apply the two extra tests, so it and the reconciliation
|
||||||
|
* screen disagreed: a reversed ₹200 deposit left the settlement screen expecting three ₹100 notes
|
||||||
|
* while the settlement check, reading the position, allowed one — and refused the hand-over.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>a breakdown whose savings transaction is reversed -- the money went back, so the notes did too.
|
||||||
|
* The table has no reversal marker of its own, so it is read off the transaction;
|
||||||
|
* <li>a breakdown that does not add up to its transaction -- ₹1,60,000 of notes against a ₹918
|
||||||
|
* deposit is mis-keyed, not cash. Sub-rupee tolerance, because a paisa-exact amount is handed
|
||||||
|
* over as the nearest whole rupee.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* <b>The transaction is found by id and account number together.</b> Savings and current
|
||||||
|
* transactions keep separate id sequences, and a current-account deposit writes its own
|
||||||
|
* transaction id into {@code txn_id}. Matched on id alone, a ₹210 current-account deposit was
|
||||||
|
* judged against an unrelated savings transaction that happened to share its id, failed the
|
||||||
|
* amount test, and vanished from the cashier's drawer. A row with no account number (older
|
||||||
|
* writers) is still matched on id alone, as before.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* All tests are correlated {@code NOT EXISTS} rather than joins, so a breakdown with no account
|
||||||
|
* transaction behind it (a Demand Draft, a manual GL entry) is untouched by them.
|
||||||
|
*/
|
||||||
|
public static final String COUNTED_ROW = " (NOT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM m_savings_account_transaction rt"
|
||||||
|
+ " JOIN m_savings_account rsa ON rsa.id = rt.savings_account_id"
|
||||||
|
+ " WHERE rt.id = std.txn_id"
|
||||||
|
+ " AND (std.account_no IS NULL OR rsa.account_no = std.account_no)"
|
||||||
|
+ " AND (rt.is_reversed = true OR " + breakdownDiffersFrom("rt.amount") + "))"
|
||||||
|
+ " AND NOT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM m_current_account_transaction rct"
|
||||||
|
+ " JOIN m_current_account rca ON rca.id = rct.current_account_id"
|
||||||
|
+ " WHERE rct.id = std.txn_id AND rca.account_no = std.account_no"
|
||||||
|
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
|
||||||
|
+ " AND" + REAL_TXN_ID + ") ";
|
||||||
|
|
||||||
|
/** 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)"
|
||||||
|
+ " 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
@@ -286,7 +286,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -310,7 +310,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -407,7 +407,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -431,7 +431,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " 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 = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
|
|||||||
+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.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
@@ -114,4 +115,16 @@ public class FundsApiResource {
|
|||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
return toApiJsonSerializer.serialize(result);
|
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 Long resourceId;
|
||||||
public PutFundsFundIdRequest changes;
|
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 createFund(JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult updateFund(Long fundId, 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.apache.fineract.portfolio.fund.serialization.FundCommandFromApiJsonDeserializer;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final FundCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
private final FundCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||||
private final FundRepository fundRepository;
|
private final FundRepository fundRepository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@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.
|
* Guaranteed to throw an exception no matter what the data integrity issue is.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-2
@@ -42,7 +42,7 @@ public class FundConfiguration {
|
|||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean(FundWritePlatformService.class)
|
@ConditionalOnMissingBean(FundWritePlatformService.class)
|
||||||
public FundWritePlatformService fundWritePlatformService(PlatformSecurityContext context,
|
public FundWritePlatformService fundWritePlatformService(PlatformSecurityContext context,
|
||||||
FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository) {
|
FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository, JdbcTemplate jdbcTemplate) {
|
||||||
return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository);
|
return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository, jdbcTemplate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-63
@@ -19,6 +19,7 @@
|
|||||||
package org.apache.fineract.portfolio.orgsetup.api;
|
package org.apache.fineract.portfolio.orgsetup.api;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
@@ -27,17 +28,18 @@ import jakarta.ws.rs.PathParam;
|
|||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.time.Instant;
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ public class BranchProfileApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -84,68 +87,23 @@ public class BranchProfileApiResource {
|
|||||||
@PUT
|
@PUT
|
||||||
@Path("{officeId}")
|
@Path("{officeId}")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
public String update(@PathParam("officeId") final Long officeId, final String body) {
|
public CommandProcessingResult update(@PathParam("officeId") final Long officeId, final String body) {
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE");
|
// The write itself (office-exists check, branch-code uniqueness check, m_office_profile
|
||||||
|
// upsert) now lives in BranchProfileWritePlatformService, invoked via
|
||||||
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?",
|
// UpdateBranchProfileCommandHandler so it goes through the generic Maker-Checker gate. The
|
||||||
Integer.class, officeId);
|
// permission check that used to happen here explicitly is now enforced by SecurityConfig's
|
||||||
if (officeExists == null || officeExists == 0) {
|
// matcher plus the command-source pipeline itself, the same as every other endpoint already
|
||||||
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
|
// migrated to this pipeline. A "locale" key is added if the body does not already carry one -
|
||||||
"Office " + officeId + " was not found.");
|
// 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 var root = this.fromJsonHelper.parse(body);
|
||||||
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
|
final JsonObject json = root.getAsJsonObject();
|
||||||
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
|
if (!json.has("locale")) {
|
||||||
final String address = this.fromJsonHelper.extractStringNamed("address", root);
|
json.addProperty("locale", "en");
|
||||||
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 CommandWrapper commandRequest = new CommandWrapperBuilder().updateBranchProfile(officeId)
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
.withJson(this.gson.toJson(json)).build();
|
||||||
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
|
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String baseSql() {
|
private String baseSql() {
|
||||||
|
|||||||
+16
-104
@@ -25,14 +25,16 @@ import jakarta.ws.rs.PUT;
|
|||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.sql.Date;
|
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
@@ -60,6 +62,7 @@ public class InstitutionProfileApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -89,100 +92,17 @@ public class InstitutionProfileApiResource {
|
|||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
public String update(final String body) {
|
public CommandProcessingResult update(final String body) {
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE");
|
// The write itself (the JDBC UPDATE of m_institution_profile) now lives in
|
||||||
ensureRowExists();
|
// InstitutionProfileWritePlatformService, invoked via UpdateInstitutionProfileCommandHandler so
|
||||||
|
// it goes through the generic Maker-Checker gate. The permission check that used to happen here
|
||||||
final var root = this.fromJsonHelper.parse(body);
|
// explicitly is now enforced by SecurityConfig's matcher plus the command-source pipeline
|
||||||
final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root);
|
// itself, the same as every other endpoint already migrated to this pipeline (Currency, Working
|
||||||
final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root);
|
// Days, Payment Type). `body` is forwarded to the command as-is - it already carries the
|
||||||
final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root);
|
// `locale` key the frontend sends, which JsonCommand's BigDecimal/date extractors would
|
||||||
final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root);
|
// otherwise require.
|
||||||
final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root);
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateInstitutionProfile().withJson(body).build();
|
||||||
final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root);
|
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureRowExists() {
|
private void ensureRowExists() {
|
||||||
@@ -204,14 +124,6 @@ public class InstitutionProfileApiResource {
|
|||||||
return codes.isEmpty() ? null : codes.get(0);
|
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) {
|
private Map<String, Object> toResult(final Map<String, Object> row) {
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
out.put("bankName", row.get("bank_name"));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.rbireserve.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved Form A / Form 9 returns: keeping a generated return so it can be read again later exactly as
|
||||||
|
* it was generated.
|
||||||
|
*
|
||||||
|
* A saved return is served from its stored snapshot, never recomputed. Recomputing would quietly
|
||||||
|
* produce a different statement from the one that was reviewed or filed - a backdated journal entry,
|
||||||
|
* a changed GL mapping or a corrected rate all move figures that were already reported - and a
|
||||||
|
* report whose figures change after it was filed is worse than no report at all.
|
||||||
|
*
|
||||||
|
* Deliberately not maker-checker: saving a snapshot changes no balance and posts no entry. The
|
||||||
|
* filing lifecycle (draft, checked, approved, submitted) is what carries the approval, and that is
|
||||||
|
* recorded on the row's status rather than in a separate command queue.
|
||||||
|
*/
|
||||||
|
@Path("/v1/rbiformafiling")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class RbiFormAFilingApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIFORMAFILING";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filing lifecycle RBI's own timetable implies: a return is drafted, checked and approved
|
||||||
|
* inside the bank, sent provisionally within a week of the fortnight ending and finally within
|
||||||
|
* twenty days, and revised if a figure turns out wrong after that.
|
||||||
|
*/
|
||||||
|
private static final List<String> STATUSES = List.of("DRAFT", "GENERATED", "CHECKED", "APPROVED",
|
||||||
|
"PROVISIONAL_SUBMITTED", "FINAL_SUBMITTED", "REVISED");
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/** Saved returns, newest first. The stored snapshot is left out - the list does not need it. */
|
||||||
|
@GET
|
||||||
|
public String retrieveAll(@QueryParam("formCode") final String formCode) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end,
|
||||||
|
f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained,
|
||||||
|
f.rate_percentage, f.remarks, f.submitted_on, f.created_on, u.username AS created_by_name
|
||||||
|
FROM m_rbi_form_a_filing f
|
||||||
|
LEFT JOIN m_appuser u ON u.id = f.created_by
|
||||||
|
""");
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
if (formCode != null && !formCode.isBlank()) {
|
||||||
|
sql.append(" WHERE f.form_code = ?");
|
||||||
|
args.add(formCode);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY f.reporting_date DESC, f.id DESC");
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
out.add(toSummary(row));
|
||||||
|
}
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One saved return, with the snapshot, so the screen can render exactly what was saved. */
|
||||||
|
@GET
|
||||||
|
@Path("{filingId}")
|
||||||
|
public String retrieveOne(@PathParam("filingId") final Long filingId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end,
|
||||||
|
f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained,
|
||||||
|
f.rate_percentage, f.remarks, f.submitted_on, f.created_on, f.report_json,
|
||||||
|
u.username AS created_by_name
|
||||||
|
FROM m_rbi_form_a_filing f
|
||||||
|
LEFT JOIN m_appuser u ON u.id = f.created_by
|
||||||
|
WHERE f.id = ?
|
||||||
|
""", filingId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
|
||||||
|
"Saved return " + filingId + " was not found.");
|
||||||
|
}
|
||||||
|
final Map<String, Object> row = rows.get(0);
|
||||||
|
final Map<String, Object> out = toSummary(row);
|
||||||
|
|
||||||
|
// The snapshot is embedded as JSON rather than as a string, so the screen receives the same
|
||||||
|
// shape the live endpoint returns and can render it through the same code path.
|
||||||
|
final String json = (String) row.get("report_json");
|
||||||
|
out.put("report", json == null || json.isBlank() ? null : gson.fromJson(json, JsonObject.class));
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a generated return. Re-saving the same form for the same reporting date replaces that
|
||||||
|
* snapshot: regenerating a return for a date already saved is a correction of it, not a second
|
||||||
|
* statement for the same day.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String create(final String body) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final JsonElement root = this.fromJsonHelper.parse(body);
|
||||||
|
final String formCode = this.fromJsonHelper.extractStringNamed("formCode", root);
|
||||||
|
final LocalDate reportingDate = this.fromJsonHelper.extractLocalDateNamed("reportingDate", root);
|
||||||
|
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
|
||||||
|
final Long officeId = this.fromJsonHelper.extractLongNamed("officeId", root);
|
||||||
|
|
||||||
|
if (formCode == null || formCode.isBlank()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.form.code",
|
||||||
|
"The form being saved must be named.");
|
||||||
|
}
|
||||||
|
if (reportingDate == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.reporting.date",
|
||||||
|
"A reporting date is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The return itself, as the screen received it. Stored verbatim: the point of a saved return
|
||||||
|
// is that it does not change afterwards, so nothing here re-derives a figure from it.
|
||||||
|
final JsonObject report = root.getAsJsonObject().getAsJsonObject("report");
|
||||||
|
if (report == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.report",
|
||||||
|
"The generated return must be supplied with the save.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
BigDecimal average = null;
|
||||||
|
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
||||||
|
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Integer existing = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class,
|
||||||
|
formCode, java.sql.Date.valueOf(reportingDate));
|
||||||
|
|
||||||
|
if (existing != null && existing > 0) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_rbi_form_a_filing
|
||||||
|
SET report_json = ?, ndtl_amount = ?, required_amount = ?, maintained_amount = ?,
|
||||||
|
average_maintained = ?, rate_percentage = ?, remarks = ?, office_id = ?,
|
||||||
|
period_start = ?, period_end = ?, fortnight_end = ?,
|
||||||
|
lastmodified_on = now(), lastmodified_by = ?
|
||||||
|
WHERE form_code = ? AND reporting_date = ?
|
||||||
|
""", gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId,
|
||||||
|
sqlDate(periodStart), sqlDate(periodEnd), sqlDate(periodEnd == null ? reportingDate : periodEnd),
|
||||||
|
this.context.authenticatedUser().getId(), formCode, java.sql.Date.valueOf(reportingDate));
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_rbi_form_a_filing
|
||||||
|
(form_code, reporting_date, fortnight_end, period_start, period_end, status,
|
||||||
|
report_json, ndtl_amount, required_amount, maintained_amount, average_maintained,
|
||||||
|
rate_percentage, remarks, office_id, created_on, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?)
|
||||||
|
""", formCode, java.sql.Date.valueOf(reportingDate),
|
||||||
|
sqlDate(periodEnd == null ? reportingDate : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
|
||||||
|
gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId,
|
||||||
|
this.context.authenticatedUser().getId());
|
||||||
|
}
|
||||||
|
return retrieveAll(formCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a saved return along its filing lifecycle. The snapshot is untouched: a return that has
|
||||||
|
* been checked and submitted must still show the figures it was checked on.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("{filingId}/status")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String updateStatus(@PathParam("filingId") final Long filingId, final String body) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final JsonElement root = this.fromJsonHelper.parse(body);
|
||||||
|
final String status = this.fromJsonHelper.extractStringNamed("status", root);
|
||||||
|
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
|
||||||
|
|
||||||
|
if (status == null || !STATUSES.contains(status)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.unknown.status",
|
||||||
|
"Status must be one of " + STATUSES + ".");
|
||||||
|
}
|
||||||
|
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE id = ?",
|
||||||
|
Integer.class, filingId);
|
||||||
|
if (exists == null || exists == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
|
||||||
|
"Saved return " + filingId + " was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The submission date is stamped when a return is actually sent, and only then - an earlier
|
||||||
|
// stage has nothing to date, and re-stamping on a later stage would lose when it was sent.
|
||||||
|
final boolean submitted = "PROVISIONAL_SUBMITTED".equals(status) || "FINAL_SUBMITTED".equals(status);
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_rbi_form_a_filing
|
||||||
|
SET status = ?,
|
||||||
|
remarks = COALESCE(?, remarks),
|
||||||
|
submitted_on = CASE WHEN ? THEN COALESCE(submitted_on, CURRENT_DATE) ELSE submitted_on END,
|
||||||
|
lastmodified_on = now(), lastmodified_by = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", status, remarks, submitted, this.context.authenticatedUser().getId(), filingId);
|
||||||
|
return retrieveAll(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discards a saved return. Only ever removes the snapshot; nothing in the ledger is touched. */
|
||||||
|
@DELETE
|
||||||
|
@Path("{filingId}")
|
||||||
|
public String delete(@PathParam("filingId") final Long filingId) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final Integer removed = jdbcTemplate.update("DELETE FROM m_rbi_form_a_filing WHERE id = ?", filingId);
|
||||||
|
if (removed == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
|
||||||
|
"Saved return " + filingId + " was not found.");
|
||||||
|
}
|
||||||
|
return retrieveAll(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toSummary(final Map<String, Object> row) {
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("id", row.get("id"));
|
||||||
|
out.put("formCode", row.get("form_code"));
|
||||||
|
out.put("reportingDate", str(row.get("reporting_date")));
|
||||||
|
out.put("periodStart", str(row.get("period_start")));
|
||||||
|
out.put("periodEnd", str(row.get("period_end")));
|
||||||
|
out.put("status", row.get("status"));
|
||||||
|
out.put("ndtlAmount", row.get("ndtl_amount"));
|
||||||
|
out.put("requiredAmount", row.get("required_amount"));
|
||||||
|
out.put("maintainedAmount", row.get("maintained_amount"));
|
||||||
|
out.put("averageMaintained", row.get("average_maintained"));
|
||||||
|
out.put("ratePercentage", row.get("rate_percentage"));
|
||||||
|
out.put("remarks", row.get("remarks"));
|
||||||
|
out.put("submittedOn", str(row.get("submitted_on")));
|
||||||
|
out.put("createdOn", str(row.get("created_on")));
|
||||||
|
out.put("createdBy", row.get("created_by_name"));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(final Object value) {
|
||||||
|
return value == null ? null : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.sql.Date sqlDate(final LocalDate date) {
|
||||||
|
return date == null ? null : java.sql.Date.valueOf(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal decimal(final JsonObject object, final String field) {
|
||||||
|
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return object.get(field).getAsBigDecimal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate localDate(final JsonObject object, final String field) {
|
||||||
|
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String value = object.get(field).getAsString();
|
||||||
|
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -228,13 +228,18 @@ public class RbiReserveConfigApiResource {
|
|||||||
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
|
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 four treatments a head can be given, each naming a branch the returns already have. */
|
||||||
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO");
|
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
|
||||||
|
"MEMO");
|
||||||
|
|
||||||
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
||||||
static {
|
static {
|
||||||
USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head");
|
USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head");
|
||||||
USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL");
|
USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL");
|
||||||
USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward what is maintained against the requirement");
|
USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward CRR maintained (Form 1)");
|
||||||
|
// Separate from MAINTAINED because the two returns admit different assets: CRR is met in
|
||||||
|
// cash and balances, SLR in cash, gold and unencumbered approved securities. One usage for
|
||||||
|
// both would let a head meant for one return quietly satisfy the other.
|
||||||
|
USAGE_DESCRIPTIONS.put("SLR_ASSET", "Counted toward SLR maintained (Form 9)");
|
||||||
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
|
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-19
@@ -117,6 +117,13 @@ public class RbiReserveReturnApiResource {
|
|||||||
private static final String USAGE_LIABILITY = "LIABILITY";
|
private static final String USAGE_LIABILITY = "LIABILITY";
|
||||||
private static final String USAGE_ASSET_NET = "ASSET_NET";
|
private static final String USAGE_ASSET_NET = "ASSET_NET";
|
||||||
private static final String USAGE_MAINTAINED = "MAINTAINED";
|
private static final String USAGE_MAINTAINED = "MAINTAINED";
|
||||||
|
/**
|
||||||
|
* Counts toward SLR rather than CRR. The two are separate because the returns admit different
|
||||||
|
* assets: CRR is met in cash and balances with banks, SLR in cash, gold and unencumbered
|
||||||
|
* approved securities. A single "maintained" usage would let a head meant for one return
|
||||||
|
* quietly satisfy the other.
|
||||||
|
*/
|
||||||
|
private static final String USAGE_SLR_ASSET = "SLR_ASSET";
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -256,21 +263,29 @@ public class RbiReserveReturnApiResource {
|
|||||||
// average across the fortnight AND a daily minimum on every single day of it, so a bank
|
// average across the fortnight AND a daily minimum on every single day of it, so a bank
|
||||||
// that held nothing for thirteen days and topped up on the last one fails even though
|
// that held nothing for thirteen days and topped up on the last one fails even though
|
||||||
// its closing position looks ample.
|
// its closing position looks ample.
|
||||||
final LocalDate fortnightStart = LocalDate.parse((String) response.get("fortnightStart"));
|
// The maintenance table covers the whole calendar month, not the fortnight alone: a
|
||||||
final LocalDate fortnightEnd = LocalDate.parse((String) response.get("fortnightEnd"));
|
// reviewer comparing two filings of the same month otherwise has to stitch two
|
||||||
|
// fortnights together by hand to see how the position moved across it.
|
||||||
|
final LocalDate fortnightStart = reportingDate.withDayOfMonth(1);
|
||||||
|
final LocalDate fortnightEnd = reportingDate.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
final List<String> maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS"));
|
final List<String> maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS"));
|
||||||
maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED));
|
maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED));
|
||||||
|
|
||||||
// Only up to the reporting date: days after it have not happened, and averaging an
|
// Only up to the reporting date: days after it have not happened, and averaging an
|
||||||
// unfinished fortnight over its whole length would understate what is held so far.
|
// unfinished fortnight over its whole length would understate what is held so far.
|
||||||
final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd;
|
final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd;
|
||||||
final Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles);
|
final Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles,
|
||||||
|
required);
|
||||||
|
|
||||||
final BigDecimal average = (BigDecimal) daily.get("average");
|
final BigDecimal average = (BigDecimal) daily.get("average");
|
||||||
final BigDecimal lowest = (BigDecimal) daily.get("lowest");
|
final BigDecimal lowest = (BigDecimal) daily.get("lowest");
|
||||||
final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2,
|
final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2,
|
||||||
RoundingMode.HALF_UP);
|
RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
daily.put("periodStart", fortnightStart.toString());
|
||||||
|
daily.put("periodEnd", fortnightEnd.toString());
|
||||||
|
daily.put("periodLabel", fortnightStart.getMonth().getDisplayName(java.time.format.TextStyle.FULL,
|
||||||
|
java.util.Locale.ENGLISH) + " " + fortnightStart.getYear());
|
||||||
daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd));
|
daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd));
|
||||||
daily.put("requiredAverage", required);
|
daily.put("requiredAverage", required);
|
||||||
daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT);
|
daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT);
|
||||||
@@ -398,31 +413,49 @@ public class RbiReserveReturnApiResource {
|
|||||||
}
|
}
|
||||||
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd));
|
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd));
|
||||||
lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd));
|
lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd));
|
||||||
|
// The heads that DO count toward SLR - gold, approved securities, eligible cash - each on
|
||||||
|
// its own line, so what makes up the maintained figure is visible rather than a single total
|
||||||
|
// a reader has to take on trust.
|
||||||
|
for (final CustomHead head : heads) {
|
||||||
|
if (USAGE_SLR_ASSET.equals(head.usage) && !inNamedSection(head)) {
|
||||||
|
lines.add(line(head.label, head.figure, monthEnd));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Shown for completeness of the month-end liquid position; not added into SLR maintained.
|
// Shown for completeness of the month-end liquid position; not added into SLR maintained.
|
||||||
// A custom MAINTAINED head IS added: it was declared as counting toward the requirement,
|
|
||||||
// and Form 9's requirement is the SLR one.
|
|
||||||
lines.add(line("Cash in hand (memo)", cash, monthEnd));
|
lines.add(line("Cash in hand (memo)", cash, monthEnd));
|
||||||
for (final CustomHead head : heads) {
|
for (final CustomHead head : heads) {
|
||||||
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) {
|
if (USAGE_LIABILITY.equals(head.usage) || USAGE_ASSET_NET.equals(head.usage)
|
||||||
// Every such head is a memo on THIS return, including one marked MAINTAINED: only
|
|| USAGE_SLR_ASSET.equals(head.usage) || inNamedSection(head)) {
|
||||||
// SLR-eligible investments count toward SLR, so the label must not suggest the
|
continue;
|
||||||
// figure was added into the maintained total when it was not.
|
|
||||||
lines.add(line(head.label + " (memo)", head.figure, monthEnd));
|
|
||||||
}
|
}
|
||||||
|
// Every remaining head is a memo on THIS return, including one marked MAINTAINED: that
|
||||||
|
// usage means CRR, so the label must not suggest the figure was added into SLR.
|
||||||
|
lines.add(line(head.label + " (memo)", head.figure, monthEnd));
|
||||||
}
|
}
|
||||||
response.put("lines", lines);
|
response.put("lines", lines);
|
||||||
|
|
||||||
final boolean computable = rate != null && dtl.configured && investments.configured;
|
// Form A's Memorandum and Annexures appear on Form 9 too - the same heads, under the same
|
||||||
|
// headings, so a reviewer reading either return finds them in the same place.
|
||||||
|
response.put("memorandum", sectionOf(heads, "Memorandum - "));
|
||||||
|
response.put("annexureA", sectionOf(heads, "Annexure A - "));
|
||||||
|
response.put("annexureB", sectionOf(heads, "Annexure B - "));
|
||||||
|
|
||||||
|
// Heads the bank declared as SLR assets - gold, approved securities, eligible cash. Summed
|
||||||
|
// here rather than on the built-in investments role alone, because SLR is maintained in
|
||||||
|
// more than government securities and a bank holding gold has no other place to state it.
|
||||||
|
final BigDecimal slrHeads = totalOf(heads, USAGE_SLR_ASSET);
|
||||||
|
final boolean anySlrAsset = investments.configured || hasUsage(heads, USAGE_SLR_ASSET);
|
||||||
|
|
||||||
|
final boolean computable = rate != null && dtl.configured && anySlrAsset;
|
||||||
response.put("computable", computable);
|
response.put("computable", computable);
|
||||||
|
|
||||||
if (computable) {
|
if (computable) {
|
||||||
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||||
// SLR admits government securities, not cash. A head marked MAINTAINED is therefore NOT
|
// A head marked MAINTAINED is deliberately NOT added: that usage means CRR, which is met
|
||||||
// added here the way it is on Form 1: it was declared as counting toward a requirement,
|
// in cash and balances with banks. Letting it count here would allow a head configured
|
||||||
// and on Form 9 only SLR-eligible investments do. Adding it would let a cash-like head
|
// for Form 1 to inflate the SLR position - exactly the error this return exists to
|
||||||
// configured for CRR inflate the SLR position, which is the error this return exists to
|
// detect. Only SLR_ASSET heads and the built-in investments role count.
|
||||||
// detect. A bank with more eligible securities maps them to SLR_INVESTMENTS.
|
final BigDecimal held = investments.amount.add(slrHeads);
|
||||||
final BigDecimal held = investments.amount;
|
|
||||||
response.put("dtlAmount", liabilities);
|
response.put("dtlAmount", liabilities);
|
||||||
response.put("demandLiabilities", dtl.configured ? demand : null);
|
response.put("demandLiabilities", dtl.configured ? demand : null);
|
||||||
response.put("timeLiabilities", dtl.configured ? time : null);
|
response.put("timeLiabilities", dtl.configured ? time : null);
|
||||||
@@ -434,7 +467,11 @@ public class RbiReserveReturnApiResource {
|
|||||||
response.put("compliant", held.compareTo(required) >= 0);
|
response.put("compliant", held.compareTo(required) >= 0);
|
||||||
response.put("maintainedPercentage", percentageOf(held, ndtl));
|
response.put("maintainedPercentage", percentageOf(held, ndtl));
|
||||||
}
|
}
|
||||||
response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments));
|
// The investments role is only reported as missing when nothing else stands in for it: a
|
||||||
|
// bank that maintains SLR in gold, or in securities mapped to its own heads, has configured
|
||||||
|
// the return even though that one built-in role is empty.
|
||||||
|
final List<String> slrUnmet = anySlrAsset ? unmet(rate, "SLR", dtl) : unmet(rate, "SLR", dtl, investments);
|
||||||
|
response.put("unmetRequirements", slrUnmet);
|
||||||
|
|
||||||
return gson.toJson(response);
|
return gson.toJson(response);
|
||||||
}
|
}
|
||||||
@@ -645,6 +682,11 @@ public class RbiReserveReturnApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The total a set of custom heads contributes under one treatment. */
|
/** The total a set of custom heads contributes under one treatment. */
|
||||||
|
/** Whether any head was declared with one usage, so "nothing mapped" can be told from "nil". */
|
||||||
|
private static boolean hasUsage(final List<CustomHead> heads, final String usage) {
|
||||||
|
return heads.stream().anyMatch(h -> usage.equals(h.usage) && h.figure.configured);
|
||||||
|
}
|
||||||
|
|
||||||
private static BigDecimal totalOf(final List<CustomHead> heads, final String usage) {
|
private static BigDecimal totalOf(final List<CustomHead> heads, final String usage) {
|
||||||
BigDecimal total = BigDecimal.ZERO;
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
for (final CustomHead head : heads) {
|
for (final CustomHead head : heads) {
|
||||||
@@ -753,7 +795,7 @@ public class RbiReserveReturnApiResource {
|
|||||||
* excluding those days would let a fortnight ending on a long weekend average away a shortfall.
|
* excluding those days would let a fortnight ending on a long weekend average away a shortfall.
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId,
|
private Map<String, Object> dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId,
|
||||||
final List<String> maintainedRoles) {
|
final List<String> maintainedRoles, final BigDecimal required) {
|
||||||
final List<Map<String, Object>> days = new ArrayList<>();
|
final List<Map<String, Object>> days = new ArrayList<>();
|
||||||
BigDecimal total = BigDecimal.ZERO;
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
BigDecimal lowest = null;
|
BigDecimal lowest = null;
|
||||||
@@ -771,7 +813,11 @@ public class RbiReserveReturnApiResource {
|
|||||||
}
|
}
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("date", day.toString());
|
row.put("date", day.toString());
|
||||||
|
// Each day carries the requirement it was measured against and the gap, so a reader can
|
||||||
|
// see WHY a day passed or failed without recomputing the percentage themselves.
|
||||||
|
row.put("required", required);
|
||||||
row.put("amount", held);
|
row.put("amount", held);
|
||||||
|
row.put("surplus", held.subtract(required));
|
||||||
days.add(row);
|
days.add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SaveRbiFormAReturnJobConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
@Autowired
|
||||||
|
private RbiFormAAutoSaveService rbiFormAAutoSaveService;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SaveRbiFormAReturnTasklet saveRbiFormAReturnTasklet() {
|
||||||
|
return new SaveRbiFormAReturnTasklet(rbiFormAAutoSaveService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step saveRbiFormAReturnStep() {
|
||||||
|
return new StepBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository)
|
||||||
|
.tasklet(saveRbiFormAReturnTasklet(), transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job saveRbiFormAReturnJob() {
|
||||||
|
return new JobBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository).start(saveRbiFormAReturnStep())
|
||||||
|
.incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService;
|
||||||
|
import org.springframework.batch.core.StepContribution;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the Form A auto-save. The service decides whether today actually closes a fortnight, so the
|
||||||
|
* tasklet stays a thin trigger and the rule lives in one place.
|
||||||
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SaveRbiFormAReturnTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final RbiFormAAutoSaveService service;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||||
|
service.saveCurrentFortnight();
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.rbireserve.service;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.rbireserve.api.RbiReserveReturnApiResource;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a copy of each fortnight's Form A without anyone having to remember to.
|
||||||
|
*
|
||||||
|
* Form 1 is filed twice a month, and what it reports is a position that cannot be recovered later: a
|
||||||
|
* backdated journal entry, a re-mapped GL account or a corrected rate all move figures that were
|
||||||
|
* already reported, so a fortnight nobody saved is a fortnight whose filed statement no longer
|
||||||
|
* exists anywhere. The job runs on the days the fortnight closes and stores the return as it stands
|
||||||
|
* at that close.
|
||||||
|
*
|
||||||
|
* It saves as DRAFT and nothing more. Checking, approving and submitting stay human acts - the job
|
||||||
|
* exists so the figures survive, not so a return files itself.
|
||||||
|
*
|
||||||
|
* It never overwrites: a fortnight already saved, by hand or by an earlier run, is left exactly as
|
||||||
|
* it is. Re-running the job must not replace a statement somebody has already reviewed.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class RbiFormAAutoSaveService {
|
||||||
|
|
||||||
|
private static final String FORM_CODE = "FORM_1";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final RbiReserveReturnApiResource returnApiResource;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the fortnight that closes on the business date, if it has not been saved already.
|
||||||
|
*
|
||||||
|
* Scheduled for the 15th and the last day of each month, but it does not trust the schedule: it
|
||||||
|
* checks the date itself, so a mis-set cron or a manual run on the wrong day cannot file a
|
||||||
|
* fortnight that has not closed.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void saveCurrentFortnight() {
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
final LocalDate monthEnd = today.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
|
||||||
|
final boolean fortnightCloses = today.getDayOfMonth() == 15 || today.equals(monthEnd);
|
||||||
|
if (!fortnightCloses) {
|
||||||
|
log.debug("RBI Form A auto-save: {} is not a fortnight end, nothing to save.", today);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Integer existing = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class,
|
||||||
|
FORM_CODE, java.sql.Date.valueOf(today));
|
||||||
|
if (existing != null && existing > 0) {
|
||||||
|
log.info("RBI Form A auto-save: {} is already saved, leaving it untouched.", today);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject report;
|
||||||
|
try {
|
||||||
|
// Computed through the same endpoint the screen uses, so an auto-saved return and one
|
||||||
|
// saved by hand cannot come out differently.
|
||||||
|
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// A return that cannot be computed is not an error worth failing the batch over - an
|
||||||
|
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
|
||||||
|
// once it is filled. Logged rather than thrown so one tenant's gap does not stop others.
|
||||||
|
log.warn("RBI Form A auto-save: could not compute the return for {}: {}", today, e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.",
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_rbi_form_a_filing
|
||||||
|
(form_code, reporting_date, fortnight_end, period_start, period_end, status, report_json,
|
||||||
|
ndtl_amount, required_amount, maintained_amount, average_maintained, rate_percentage,
|
||||||
|
remarks, created_on)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
|
||||||
|
""", FORM_CODE, java.sql.Date.valueOf(today),
|
||||||
|
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
|
||||||
|
gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
|
||||||
|
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
|
||||||
|
"Saved automatically at the close of the fortnight.");
|
||||||
|
|
||||||
|
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offices the return could be scoped to. Unused today - the job saves the consolidated return. */
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private List<Map<String, Object>> offices() {
|
||||||
|
return jdbcTemplate.queryForList("SELECT id, name FROM m_office ORDER BY id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.sql.Date sqlDate(final LocalDate date) {
|
||||||
|
return date == null ? null : java.sql.Date.valueOf(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal decimal(final JsonObject object, final String field) {
|
||||||
|
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return object.get(field).getAsBigDecimal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate date(final JsonObject object, final String field) {
|
||||||
|
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String value = object.get(field).getAsString();
|
||||||
|
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-132
@@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
account.setClosedOnDate(closedDate);
|
account.setClosedOnDate(closedDate);
|
||||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
if (onClosureType.isReinvestPrincipalAndInterest()) {
|
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) {
|
||||||
// Renews on the SAME account, so the customer keeps one account number however many times
|
// Renews on the SAME account, so the customer keeps one account number however many times
|
||||||
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
|
// the deposit rolls over -- Reinvest Principal Only renews just the closing principal here
|
||||||
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
|
// (below), with only the leftover interest actually leaving, rather than the legacy
|
||||||
// rollover itself -- which also removes the transfer-suspense round trip the old
|
// close-and-reopen this branch used to use, which left the customer with a second account
|
||||||
// close-and-reopen flow needed.
|
// number for something that renews here just as cleanly as Reinvest Principal + Interest
|
||||||
|
// does. Full reinvest itself still moves nothing: the whole matured balance simply becomes
|
||||||
|
// the new term's principal, so there is no withdrawal, no deposit and no journal entry for
|
||||||
|
// the rollover itself.
|
||||||
final BigDecimal closingPrincipal = account.getDepositAmount();
|
final BigDecimal closingPrincipal = account.getDepositAmount();
|
||||||
final BigDecimal maturedBalance = account.getAccountBalance();
|
final BigDecimal maturedBalance = account.getAccountBalance();
|
||||||
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
|
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
|
||||||
: maturedBalance.subtract(closingPrincipal);
|
: maturedBalance.subtract(closingPrincipal);
|
||||||
|
final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance;
|
||||||
|
// Whatever doesn't renew is interest the customer earned and has to actually be paid out
|
||||||
|
// somewhere -- validated before anything below runs, so a missing target fails cleanly with
|
||||||
|
// nothing done rather than leaving a half-completed reinvestment.
|
||||||
|
final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount);
|
||||||
|
if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException(
|
||||||
|
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
|
||||||
|
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
|
||||||
|
}
|
||||||
|
|
||||||
// The active CBS base interest-rate version for this FD type: the same rate card the Base
|
// The active CBS base interest-rate version for this FD type: the same rate card the Base
|
||||||
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
|
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
|
||||||
@@ -337,66 +350,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final Integer closingTenure = account.depositPeriod();
|
final Integer closingTenure = account.depositPeriod();
|
||||||
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
|
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
|
||||||
|
|
||||||
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
|
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
|
||||||
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
|
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
|
||||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
financialYearBeginningMonth);
|
financialYearBeginningMonth);
|
||||||
this.savingsAccountRepository.save(account);
|
this.savingsAccountRepository.save(account);
|
||||||
|
|
||||||
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
|
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
|
||||||
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
|
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
|
||||||
closingTenure, closingTenureFrequencyEnum);
|
closingTenure, closingTenureFrequencyEnum);
|
||||||
changes.put("renewedInPlace", Boolean.TRUE);
|
changes.put("renewedInPlace", Boolean.TRUE);
|
||||||
changes.put("renewalCycleNo", account.renewalCycleNo());
|
changes.put("renewalCycleNo", account.renewalCycleNo());
|
||||||
|
|
||||||
} else if (onClosureType.isReinvest()) {
|
// Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is
|
||||||
// "Re-invest principal only" still closes and reopens, because only the principal carries
|
// zero (renewedAmount already equals maturedBalance), so this stays a no-op for it.
|
||||||
// forward and the interest has to leave the deposit -- which needs a payout destination that
|
|
||||||
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
|
|
||||||
final BigDecimal maturedBalance = account.getAccountBalance();
|
|
||||||
final BigDecimal reInvestAmount = account.getDepositAmount();
|
|
||||||
// Whatever is left after the principal moves into the new deposit is interest the customer
|
|
||||||
// earned and has to actually be paid out somewhere -- it must not just sit on this account,
|
|
||||||
// which is about to be closed. Validated before anything below runs, so a missing target
|
|
||||||
// fails cleanly with nothing done rather than leaving a half-completed reinvestment.
|
|
||||||
final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount);
|
|
||||||
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
|
|
||||||
throw new GeneralPlatformDomainRuleException(
|
|
||||||
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
|
|
||||||
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
|
|
||||||
}
|
|
||||||
ExternalId externalId = this.externalIdFactory.create();
|
|
||||||
|
|
||||||
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
|
|
||||||
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
|
|
||||||
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
|
||||||
|
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
|
||||||
autoGenerateAccountNumber(reinvestedDeposit);
|
|
||||||
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
|
|
||||||
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
|
|
||||||
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
|
|
||||||
final boolean isInternalTransferLeg = true;
|
|
||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
|
||||||
false, isRegularTransaction, isInternalTransferLeg);
|
|
||||||
savingsTransactionId = withdrawal.getId();
|
|
||||||
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
|
|
||||||
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
|
|
||||||
// postJournalEntries() call at the end of this method posts it a second time.
|
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
|
||||||
|
|
||||||
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
|
||||||
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
|
|
||||||
this.savingsAccountRepository.save(reinvestedDeposit);
|
|
||||||
|
|
||||||
// Reached from the maturity job processing the account's standing maturity instruction.
|
|
||||||
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
|
|
||||||
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
|
|
||||||
|
|
||||||
// Pay out whatever didn't move into the new deposit, then actually close this account --
|
|
||||||
// previously this branch left the leftover interest stranded on an account that was never
|
|
||||||
// closed, silently, because nothing past this point ever referenced toSavingsId at all.
|
|
||||||
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
|
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
@@ -409,8 +376,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
|
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
}
|
}
|
||||||
account.updateClosedStatus();
|
|
||||||
account.updateOnAccountClosureStatus(onClosureType);
|
|
||||||
|
|
||||||
} else if (onClosureType.isTransferToSavings()) {
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
@@ -751,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
|
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
|
||||||
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
|
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
|
||||||
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
|
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
|
||||||
|
// Only on this first-ever close of this term -- otherwise a term that renews more than once
|
||||||
|
// (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the
|
||||||
|
// same chart against the same closingRow every time, piling up duplicate slab rows for a
|
||||||
|
// chart that only ever needs archiving once.
|
||||||
|
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
|
||||||
}
|
}
|
||||||
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
|
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
|
||||||
closingRow.markMatured();
|
closingRow.markMatured();
|
||||||
this.fixedDepositRenewalHistoryRepository.save(closingRow);
|
this.fixedDepositRenewalHistoryRepository.save(closingRow);
|
||||||
|
|
||||||
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
|
|
||||||
|
|
||||||
// The new term's own interest/maturity stay unset until it closes in turn.
|
// The new term's own interest/maturity stay unset until it closes in turn.
|
||||||
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
|
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
|
||||||
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
|
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
|
||||||
@@ -884,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
|
* 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
|
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
|
||||||
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
|
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
|
||||||
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
|
* transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and
|
||||||
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
|
* Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just
|
||||||
* the existing manual Close command still uses today.
|
* Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with
|
||||||
|
* only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}
|
||||||
|
* this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's
|
||||||
|
* own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an
|
||||||
|
* already-stored instruction).
|
||||||
* <p>
|
* <p>
|
||||||
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
|
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
|
||||||
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
|
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
|
||||||
@@ -953,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
||||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
|
|
||||||
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
|
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
|
||||||
// These two branches keep the account open, so they never reach close()/prematureClosure() and
|
// These three branches keep the account open, so they never reach close()/prematureClosure()
|
||||||
// would otherwise accept any date at all -- including one behind already-posted interest, which
|
// and would otherwise accept any date at all -- including one behind already-posted interest,
|
||||||
// the renewal would then turn into a pivot that reverses and re-posts it.
|
// which the renewal would then turn into a pivot that reverses and re-posts it.
|
||||||
account.validateRenewalDate(closedDate);
|
account.validateRenewalDate(closedDate);
|
||||||
|
|
||||||
final BigDecimal closingPrincipal = account.getDepositAmount();
|
final BigDecimal closingPrincipal = account.getDepositAmount();
|
||||||
@@ -972,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
|
||||||
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
|
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
|
||||||
}
|
}
|
||||||
|
} else if (onClosureType.isReinvestPrincipal()) {
|
||||||
|
// Exactly the closing term's own principal -- the fixed point on the same split Partial
|
||||||
|
// Reinvest lets staff pick freely. Renews in place same as the other two, on just this
|
||||||
|
// account, with only the interest actually leaving (below) -- no second account, unlike the
|
||||||
|
// legacy close-and-reopen this branch used to do.
|
||||||
|
renewedAmount = closingPrincipal;
|
||||||
} else {
|
} else {
|
||||||
renewedAmount = maturedBalance;
|
renewedAmount = maturedBalance;
|
||||||
}
|
}
|
||||||
@@ -1004,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
changes.put("renewedInPlace", Boolean.TRUE);
|
changes.put("renewedInPlace", Boolean.TRUE);
|
||||||
changes.put("renewalCycleNo", account.renewalCycleNo());
|
changes.put("renewalCycleNo", account.renewalCycleNo());
|
||||||
|
|
||||||
if (onClosureType.isPartialReinvest()) {
|
if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
|
||||||
// The kept portion never left the account -- renewInPlace() just relabels it as the new
|
// The kept portion never left the account -- renewInPlace() just relabels it as the new
|
||||||
// term's principal -- so only the remainder is an actual money movement, posted the same
|
// term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is
|
||||||
// way the Transfer to Savings branch below posts its full-balance transfer.
|
// an actual money movement, posted the same way the Transfer to Savings branch below posts
|
||||||
|
// its full-balance transfer.
|
||||||
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
|
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
final boolean isExceptionForBalanceCheck = false;
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
|
final boolean isExceptionForBalanceCheck = false;
|
||||||
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
|
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder,
|
||||||
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
|
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
|
||||||
isAccountTransfer, isExceptionForBalanceCheck);
|
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
||||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||||
}
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
}
|
||||||
} else if (onClosureType.isReinvestPrincipal()) {
|
|
||||||
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
|
|
||||||
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
|
|
||||||
// has nowhere to send.
|
|
||||||
final BigDecimal maturedBalance = account.getAccountBalance();
|
|
||||||
final BigDecimal reInvestAmount = account.getDepositAmount();
|
|
||||||
final ExternalId externalId = this.externalIdFactory.create();
|
|
||||||
|
|
||||||
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
|
|
||||||
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
|
|
||||||
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
|
||||||
|
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
|
||||||
autoGenerateAccountNumber(reinvestedDeposit);
|
|
||||||
final boolean isInternalTransferLeg = true;
|
|
||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
|
||||||
false, isRegularTransaction, isInternalTransferLeg);
|
|
||||||
savingsTransactionId = withdrawal.getId();
|
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
|
||||||
|
|
||||||
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
|
||||||
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
|
|
||||||
this.savingsAccountRepository.save(reinvestedDeposit);
|
|
||||||
|
|
||||||
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
|
|
||||||
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
|
|
||||||
|
|
||||||
// Only the principal moved into the new deposit -- the interest earned on this term is still
|
|
||||||
// sitting in this account's balance. Pay it out to the chosen savings account now, before
|
|
||||||
// closing, rather than stranding it here with nowhere left to go.
|
|
||||||
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
|
|
||||||
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
|
||||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
|
||||||
final boolean isExceptionForBalanceCheck = false;
|
|
||||||
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
|
|
||||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
|
|
||||||
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
|
||||||
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
|
|
||||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alreadyMatured) {
|
|
||||||
account.close(user, command, changes);
|
|
||||||
} else {
|
|
||||||
account.prematureClosure(user, command, changes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (onClosureType.isTransferToSavings()) {
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
|
|||||||
+10
-2
@@ -250,7 +250,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement elsewhere.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +310,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement elsewhere.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-34
@@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
|
|
||||||
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
|
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
|
||||||
|
|
||||||
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
|
// Reinvest Principal Only now renews in place on this same account (see
|
||||||
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
|
// DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually
|
||||||
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
|
// set any more, but this guard is left as a harmless no-op rather than removed, in case a future
|
||||||
|
// closure type reintroduces a genuine close-and-reopen.
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
||||||
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
||||||
@@ -1566,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
// would treat it as new and post it a second time without this.
|
// would treat it as new and post it a second time without this.
|
||||||
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
|
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
|
||||||
|
|
||||||
|
// Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never
|
||||||
|
// actually set any more, but this guard is left as a harmless no-op rather than removed,
|
||||||
|
// in case a future closure type reintroduces a genuine close-and-reopen.
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
||||||
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
||||||
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
||||||
// activateWithBalance() always returns zero here -- it exists to stop a manually
|
|
||||||
// opened FD from auto-crediting itself at activation (funding is supposed to be a
|
|
||||||
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
|
|
||||||
// withdrawn from the matured account and needs to land in the new one as part of
|
|
||||||
// this same automated closure, so it has to come from what reInvest() actually
|
|
||||||
// carried over onto the new account, not from that unrelated activation guard.
|
|
||||||
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
|
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
|
||||||
Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
||||||
if (activationChargeAmount.isGreaterThanZero()) {
|
if (activationChargeAmount.isGreaterThanZero()) {
|
||||||
@@ -1663,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
|
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
|
||||||
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||||
|
|
||||||
// REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId);
|
// Both reinvest types now renew this same account in place (see
|
||||||
// REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that
|
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never
|
||||||
// actually carries the new term's maturity date/amount either way.
|
// actually set any more, but this guard is left as a harmless no-op rather than removed, in case
|
||||||
|
// a future closure type reintroduces a genuine close-and-reopen.
|
||||||
FixedDepositAccount renewedAccount = fdAccount;
|
FixedDepositAccount renewedAccount = fdAccount;
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
|
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
|
||||||
@@ -1704,9 +1703,11 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
/**
|
/**
|
||||||
* Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment
|
* Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment
|
||||||
* dialog before the staff member confirms anything -- the actual current balance, the linked
|
* dialog before the staff member confirms anything -- the actual current balance, the linked
|
||||||
* account's rate, the gap-day count, the computed gap-interest amount, and the balance the FD will
|
* account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid
|
||||||
* carry into its new term once that interest lands. Mirrors {@link #creditGapInterest} exactly but
|
* onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so
|
||||||
* never posts anything; a within-grace account gets zero for the gap fields since nothing is owed.
|
* projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews.
|
||||||
|
* Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets
|
||||||
|
* zero for the gap fields since nothing is owed.
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Override
|
@Override
|
||||||
@@ -1750,12 +1751,15 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
result.put("projectedBalance", null);
|
result.put("projectedBalance", null);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId));
|
||||||
|
|
||||||
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
|
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
|
||||||
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
|
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
|
||||||
result.put("linkedAccountRate", linkedAccountRate);
|
result.put("linkedAccountRate", linkedAccountRate);
|
||||||
result.put("gapInterestAmount", gapInterestAmount);
|
result.put("gapInterestAmount", gapInterestAmount);
|
||||||
result.put("projectedBalance", currentBalance.add(gapInterestAmount));
|
// Paid onto the linked savings account, not this FD -- what renews is still just the FD's own
|
||||||
|
// current balance.
|
||||||
|
result.put("projectedBalance", currentBalance);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1790,6 +1794,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
""", BigDecimal.class, linkedAccountId);
|
""", BigDecimal.class, linkedAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} --
|
||||||
|
* shown in the Renewal Preview so staff can see exactly which account the gap interest is about
|
||||||
|
* to land on, not just that "a linked account" exists. */
|
||||||
|
private String resolveLinkedAccountNo(final Long linkedAccountId) {
|
||||||
|
return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Simple interest on the FD's own current balance, for the stretch between its maturity date and
|
/** Simple interest on the FD's own current balance, for the stretch between its maturity date and
|
||||||
* today, at the linked account's product rate -- the pure calculation, shared by the posting path
|
* today, at the linked account's product rate -- the pure calculation, shared by the posting path
|
||||||
* ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number
|
* ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number
|
||||||
@@ -1805,23 +1816,30 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
|
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
|
||||||
* linked Savings/Current account's product -- credited onto the FD itself as a genuine
|
* linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine
|
||||||
* INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the
|
* INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in
|
||||||
* cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD
|
* the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at
|
||||||
* Dashboard's generic Payment command) reads the account's balance. Shared by
|
* the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding
|
||||||
* {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's
|
* it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings
|
||||||
* Renew action now calls the latter as a pre-step before submitting the same generic `payment`
|
* interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay
|
||||||
* command the account's own Account Actions menu already uses, so every closure type (Transfer to
|
* it out through the FD's own closure math instead of it already being where it belongs. The FD's
|
||||||
* Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the
|
* own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or
|
||||||
* two this method's sibling endpoint originally supported.
|
* the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap
|
||||||
|
* interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit}
|
||||||
|
* and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as
|
||||||
|
* a pre-step before submitting the same generic `payment` command the account's own Account Actions
|
||||||
|
* menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial
|
||||||
|
* Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint
|
||||||
|
* originally supported.
|
||||||
*/
|
*/
|
||||||
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
|
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
|
||||||
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
|
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
|
||||||
if (linkedAccountId == null) {
|
if (linkedAccountId == null) {
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
|
||||||
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it "
|
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it "
|
||||||
+ "before renewing late, so the gap-period interest has a rate to use.");
|
+ "before renewing late, so the gap-period interest has somewhere to be paid.");
|
||||||
}
|
}
|
||||||
|
final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
|
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
|
||||||
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
|
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
|
||||||
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
|
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
|
||||||
@@ -1829,14 +1847,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
|
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||||
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
||||||
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||||
|
|
||||||
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(),
|
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount,
|
||||||
today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true);
|
linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true);
|
||||||
fdAccount.addTransaction(gapInterestTxn);
|
linkedAccount.addTransaction(gapInterestTxn);
|
||||||
fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions());
|
linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions());
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
|
this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount);
|
||||||
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||||
}
|
}
|
||||||
return gapInterestAmount;
|
return gapInterestAmount;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -260,7 +260,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +354,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -264,7 +264,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +361,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -203,7 +203,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +269,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -420,7 +420,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +528,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -172,7 +172,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
|||||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||||
|
// it just no longer lets them bypass the hold on their own (matches
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||||
|
// equivalent rule for the classic command pipeline).
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +219,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
|||||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||||
|
// it just no longer lets them bypass the hold on their own (matches
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||||
|
// equivalent rule for the classic command pipeline).
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -882,7 +882,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,7 +972,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||||
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||||
|
// hold CHECKER_SUPER_USER - see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||||
|
// equivalent enforcement in the classic command pipeline.
|
||||||
|
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-94
@@ -34,23 +34,19 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.WebApplicationException;
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.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.portfolio.sponsordd.domain.SponsorBankRepository;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
|
|
||||||
@Path("/v1/sponsorbanks")
|
@Path("/v1/sponsorbanks")
|
||||||
@Component
|
@Component
|
||||||
@@ -59,8 +55,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
public class SponsorBankApiResource {
|
public class SponsorBankApiResource {
|
||||||
|
|
||||||
private final SponsorBankRepository sponsorBankRepository;
|
private final SponsorBankRepository sponsorBankRepository;
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
/** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */
|
/** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */
|
||||||
@@ -125,78 +121,19 @@ public class SponsorBankApiResource {
|
|||||||
return names;
|
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
|
@POST
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.")
|
@Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.")
|
||||||
public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) {
|
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(() -> {
|
return safely(() -> {
|
||||||
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSponsorBank()
|
||||||
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
|
.withJson(this.gson.toJson(request)).build();
|
||||||
throw new IllegalArgumentException(
|
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
"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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.")
|
@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,
|
public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId,
|
||||||
final SponsorBankRequest request) {
|
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(() -> {
|
return safely(() -> {
|
||||||
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorBank(sponsorBankId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found."));
|
.withJson(this.gson.toJson(request)).build();
|
||||||
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
&& !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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-10
@@ -41,6 +41,9 @@ import java.util.Collection;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.CommandProcessingResult;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData;
|
||||||
@@ -76,6 +79,7 @@ public class SponsorDDApiResource {
|
|||||||
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
||||||
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
|
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
|
||||||
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
@@ -539,16 +543,28 @@ public class SponsorDDApiResource {
|
|||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@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.")
|
@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) {
|
public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) {
|
||||||
return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping(
|
// Routed through the generic command-source pipeline (CommandWrapperBuilder ->
|
||||||
request.getSponsorBankId(),
|
// logCommandSource() -> UpdateSponsorDdGlMappingCommandHandler) so this genuinely gains
|
||||||
request.getDdPayableGlAccountId(),
|
// Maker-Checker support; the write itself (issueRequestWritePlatformService.upsertGlMapping)
|
||||||
request.getChequeClearingGlAccountId(),
|
// is unchanged and now called from that handler instead of directly from here. The JSON is
|
||||||
request.getCashGlAccountId(),
|
// built by hand (not Gson-serializing `request` directly) purely to include a "locale" key -
|
||||||
request.getChargesIncomeGlAccountId(),
|
// JsonCommand's BigDecimal extractor requires one, and GlMappingRequest has no such field.
|
||||||
request.getCashSuspenseGlAccountId(),
|
return safely(() -> {
|
||||||
request.getGstPercentage(),
|
final JsonObject json = new JsonObject();
|
||||||
request.getGstPayableGlAccountId(),
|
json.addProperty("sponsorBankId", request.getSponsorBankId());
|
||||||
request.getDdLimitAmount()));
|
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
|
@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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-9
@@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
@@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
final Long drGlAccountId;
|
final Long drGlAccountId;
|
||||||
Long linkedAccountId = null;
|
Long linkedAccountId = null;
|
||||||
|
boolean linkedAccountIsCurrent = false;
|
||||||
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
||||||
final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null;
|
final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null;
|
||||||
final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null;
|
final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null;
|
||||||
@@ -627,6 +629,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
issuingBranchId, issuedDate, currentUser.getId(), note);
|
issuingBranchId, issuedDate, currentUser.getId(), note);
|
||||||
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
||||||
linkedAccountId = currentAccountId;
|
linkedAccountId = currentAccountId;
|
||||||
|
linkedAccountIsCurrent = true;
|
||||||
}
|
}
|
||||||
if (drGlAccountId == null) {
|
if (drGlAccountId == null) {
|
||||||
throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping.");
|
throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping.");
|
||||||
@@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
|
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
|
||||||
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount,
|
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount,
|
||||||
chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId);
|
chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
|
||||||
|
linkedAccountIsCurrent);
|
||||||
|
|
||||||
final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft(
|
final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft(
|
||||||
leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName,
|
leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName,
|
||||||
@@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
Long resultingCurrentTransactionId = null;
|
Long resultingCurrentTransactionId = null;
|
||||||
final Long drGlAccountId;
|
final Long drGlAccountId;
|
||||||
Long linkedAccountId = null;
|
Long linkedAccountId = null;
|
||||||
|
boolean linkedAccountIsCurrent = false;
|
||||||
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
||||||
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
||||||
// Book debit, no journal entry of its own -- see approveIssueRequest().
|
// Book debit, no journal entry of its own -- see approveIssueRequest().
|
||||||
@@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
issuingBranchId, issuedDate, currentUser.getId(), note);
|
issuingBranchId, issuedDate, currentUser.getId(), note);
|
||||||
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
||||||
linkedAccountId = currentAccountId;
|
linkedAccountId = currentAccountId;
|
||||||
|
linkedAccountIsCurrent = true;
|
||||||
}
|
}
|
||||||
if (drGlAccountId == null) {
|
if (drGlAccountId == null) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
@@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
||||||
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges,
|
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges,
|
||||||
chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId);
|
chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
|
||||||
|
linkedAccountIsCurrent);
|
||||||
|
|
||||||
for (final Map<String, Object> request : requests) {
|
for (final Map<String, Object> request : requests) {
|
||||||
this.sponsorDDWritePlatformService.issueDemandDraft(
|
this.sponsorDDWritePlatformService.issueDemandDraft(
|
||||||
@@ -1757,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
* liability, so they get their own credit leg rather than being folded into the liability). Resolves
|
* liability, so they get their own credit leg rather than being folded into the liability). Resolves
|
||||||
* the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request /
|
* the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request /
|
||||||
* m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's
|
* m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's
|
||||||
* savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 /
|
* savings/current account for an account-debit DD), the Dr-side row is tagged
|
||||||
* entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching
|
* entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so
|
||||||
* how Fineract's own auto-generated entries display.
|
* GL reports show the account number instead of a blank "—", matching how Fineract's own
|
||||||
|
* auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account
|
||||||
|
* each with their own sequence) can't resolve a same-numbered stranger's account instead.
|
||||||
*/
|
*/
|
||||||
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
||||||
final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate,
|
final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate,
|
||||||
final String comments, final Long linkedAccountId) {
|
final String comments, final Long linkedAccountId) {
|
||||||
return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId,
|
return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId,
|
||||||
txnDate, comments, linkedAccountId);
|
txnDate, comments, linkedAccountId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1811,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
|
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
|
||||||
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
||||||
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
|
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
|
||||||
final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) {
|
final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId,
|
||||||
|
final boolean linkedAccountIsCurrent) {
|
||||||
|
|
||||||
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
|
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
|
||||||
final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0;
|
final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0;
|
||||||
@@ -1872,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
|
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
|
||||||
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
|
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
|
||||||
if (linkedAccountId != null) {
|
if (linkedAccountId != null) {
|
||||||
|
// m_current_account and m_savings_account each keep their own identity sequence, so the same
|
||||||
|
// numeric id can be a real row in both -- entity_type_enum must say which one linkedAccountId
|
||||||
|
// names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit
|
||||||
|
// branch resolves either a savingsAccountId or a currentAccountId into this same parameter).
|
||||||
|
final int entityType = linkedAccountIsCurrent
|
||||||
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
this.jdbcTemplate.update(
|
this.jdbcTemplate.update(
|
||||||
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ("
|
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ("
|
||||||
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
|
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
|
||||||
+ " ORDER BY id DESC LIMIT 1)",
|
+ " ORDER BY id DESC LIMIT 1)",
|
||||||
linkedAccountId, transactionId);
|
entityType, linkedAccountId, transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.spm.api;
|
package org.apache.fineract.spm.api;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
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.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.exception.UnrecognizedQueryParamException;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.spm.data.SurveyData;
|
import org.apache.fineract.spm.data.SurveyData;
|
||||||
import org.apache.fineract.spm.domain.Survey;
|
import org.apache.fineract.spm.domain.Survey;
|
||||||
@@ -57,6 +60,8 @@ public class SpmApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext securityContext;
|
private final PlatformSecurityContext securityContext;
|
||||||
private final SpmService spmService;
|
private final SpmService spmService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@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"
|
@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")
|
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
|
||||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||||
public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) {
|
public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) {
|
||||||
this.securityContext.authenticatedUser();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build();
|
||||||
final Survey survey = SurveyMapper.map(surveyData, new Survey());
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
this.spmService.createSurvey(survey);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
return getResponse(survey.getId());
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@@ -116,12 +119,10 @@ public class SpmApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Transactional
|
@Transactional
|
||||||
public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) {
|
public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) {
|
||||||
this.securityContext.authenticatedUser();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build();
|
||||||
final Survey surveyToUpdate = this.spmService.findById(id);
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
this.spmService.updateSurvey(survey);
|
|
||||||
return getResponse(survey.getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -131,22 +132,17 @@ public class SpmApiResource {
|
|||||||
@Transactional
|
@Transactional
|
||||||
@Operation(summary = "Deactivate Survey", description = "")
|
@Operation(summary = "Deactivate Survey", description = "")
|
||||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||||
public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
||||||
this.securityContext.authenticatedUser();
|
final CommandWrapperBuilder builder = new CommandWrapperBuilder();
|
||||||
|
final CommandWrapper commandRequest;
|
||||||
if (command != null && command.equalsIgnoreCase("activate")) {
|
if (command != null && command.equalsIgnoreCase("activate")) {
|
||||||
this.spmService.activateSurvey(id);
|
commandRequest = builder.activateSurvey(id).build();
|
||||||
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
|
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
|
||||||
this.spmService.deactivateSurvey(id);
|
commandRequest = builder.deactivateSurvey(id).build();
|
||||||
} else {
|
} else {
|
||||||
throw new UnrecognizedQueryParamException("command", command);
|
throw new UnrecognizedQueryParamException("command", command);
|
||||||
}
|
}
|
||||||
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
}
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
|
||||||
private String getResponse(Long id) {
|
|
||||||
Gson gson = new Gson();
|
|
||||||
HashMap<String, Object> response = new HashMap<>();
|
|
||||||
response.put("resourceId", id);
|
|
||||||
return gson.toJson(response);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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";
|
+ " 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() {
|
public String makerCheckerablePermissionSchema() {
|
||||||
/*
|
/*
|
||||||
* get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping,
|
* 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"
|
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")
|
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping")
|
||||||
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
|
+ " != '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";
|
+ " 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.domain.JdbcSupport;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
|
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.Page;
|
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.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.UserPendingRequest;
|
||||||
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
|
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
@@ -51,8 +57,13 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo
|
|||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
|
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
|
||||||
|
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||||
|
private final StaffRepositoryWrapper staffRepositoryWrapper;
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
|
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
|
||||||
|
|
||||||
|
private static final String UNKNOWN_REFERENCE_FORMAT = "Unknown (ID: %s)";
|
||||||
|
|
||||||
private static final String SELECT_SQL = """
|
private static final String SELECT_SQL = """
|
||||||
SELECT r.id AS id,
|
SELECT r.id AS id,
|
||||||
r.user_id AS userId,
|
r.user_id AS userId,
|
||||||
@@ -115,7 +126,96 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo
|
|||||||
public UserPendingRequestData retrievePendingRequest(final Long id) {
|
public UserPendingRequestData retrievePendingRequest(final Long id) {
|
||||||
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
|
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
|
||||||
final String sql = SELECT_SQL + " WHERE r.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> {
|
private static class UserPendingRowMapper implements RowMapper<UserPendingRequestData> {
|
||||||
|
|||||||
+1
-1
@@ -162,8 +162,8 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
|||||||
originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null);
|
originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null);
|
||||||
originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null);
|
originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null);
|
||||||
originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : 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("passwordNeverExpires", userToUpdate.getPasswordNeverExpires());
|
||||||
originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser());
|
|
||||||
final Set<String> roleNames = new HashSet<>();
|
final Set<String> roleNames = new HashSet<>();
|
||||||
final Set<Long> roleIds = new HashSet<>();
|
final Set<Long> roleIds = new HashSet<>();
|
||||||
if (userToUpdate.getRoles() != null) {
|
if (userToUpdate.getRoles() != null) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
|||||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
|
||||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
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.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.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
|
||||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||||
|
|||||||
+1
@@ -32,4 +32,5 @@
|
|||||||
<include file="parts/0009_set_and_encrypt_ro_if_not_exists.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0009_set_and_encrypt_ro_if_not_exists.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0010_set_datetime_precision.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0010_set_datetime_precision.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0011_create_api_client.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0011_create_api_client.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0012_seed_default_platform_api_client.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- Seeds ONE fixed, well-known platform-operator API key on every deployment, so
|
||||||
|
#/platform-admin/menu-access works out of the box the first time the app is stood up,
|
||||||
|
without anyone needing shell/DB access to mint the first key by hand. It is deliberately
|
||||||
|
the SAME raw value on every deployment/tenant (requested behaviour) rather than a random
|
||||||
|
one generated per install - the trade-off, on purpose: this value is visible to anyone who
|
||||||
|
can read this source file, exactly like a default admin password shipped in a seed script.
|
||||||
|
It is a bootstrap convenience, not a secret. Rotate it for any deployment that is not purely
|
||||||
|
local/dev: insert a new row with a real random key's hash (see ApiClientCredentialService's
|
||||||
|
hashing) and either deactivate or DELETE this row - POST /v1/apiclients is intentionally
|
||||||
|
disabled (see ApiClientAdminApiResource), so rotation is a direct m_api_client write, same
|
||||||
|
as this seed.
|
||||||
|
|
||||||
|
Raw key (only shown here, since it is fixed and not a secret to rotate away from):
|
||||||
|
cbsx_default_platform_operator_key_2026
|
||||||
|
api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase - matching
|
||||||
|
ApiClientCredentialService#sha256Hex. -->
|
||||||
|
<changeSet id="0012_seed_default_platform_api_client" author="cbs-nitesh">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_api_client"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_api_client
|
||||||
|
WHERE api_key_hash = '9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_api_client">
|
||||||
|
<column name="client_name" value="Default Platform Operator (rotate before real production use)"/>
|
||||||
|
<column name="api_key_hash" value="9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd"/>
|
||||||
|
<column name="api_key_prefix" value="cbsx_default"/>
|
||||||
|
<column name="allowed_ips"/>
|
||||||
|
<column name="created_by"/>
|
||||||
|
<column name="created_by_tenant" value="ALL"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -120,6 +120,15 @@
|
|||||||
<include file="parts/0572_add_renewal_grace_period_to_deposit_product_term.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0572_add_renewal_grace_period_to_deposit_product_term.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0573_add_renewal_grace_period_to_saving_product_temp.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0573_add_renewal_grace_period_to_saving_product_temp.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0574_add_renewal_grace_period_to_deposit_account_term.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0574_add_renewal_grace_period_to_deposit_account_term.xml" relativeToChangelogFile="true"/>
|
||||||
|
<!-- NOTE: numbered 0571/0572 like the dev-latest FD migrations directly above - both were added
|
||||||
|
independently (this Maker-Checker work on feature/bugfix, the FD work on dev-latest) before
|
||||||
|
either branch saw the other's changes, so the numeric prefixes collide. Left as originally
|
||||||
|
named (not renumbered) since only the changelog include list, not the migration files
|
||||||
|
themselves, is in scope for this merge; Liquibase identifies changesets by id+author+file
|
||||||
|
path, not by filename number, so the collision is cosmetic only and does not affect
|
||||||
|
correctness. -->
|
||||||
|
<include file="parts/0571_add_sponsor_dd_gl_mapping_permission.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0572_add_sponsor_bank_update_permission.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0571_create_rbi_reserve_config.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0571_create_rbi_reserve_config.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0572_create_oss_return_permissions.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0572_create_oss_return_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml" relativeToChangelogFile="true"/>
|
||||||
@@ -132,5 +141,20 @@
|
|||||||
<include file="parts/0579_current_product_gl_mappings_own_product_type.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0579_current_product_gl_mappings_own_product_type.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0580_unique_core_product_gl_mapping.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0580_unique_core_product_gl_mapping.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0581_form_a_completion.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0581_form_a_completion.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0582_form_a_saved_report.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0583_save_rbi_form_a_return_job.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0584_form9_slr_assets.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
|
||||||
|
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
|
||||||
|
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
|
||||||
|
never appeared on either side of this merge as a real conflict - the collision only became
|
||||||
|
visible once the stash was inspected). Renumbered to 0583 (the first number free after
|
||||||
|
dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file
|
||||||
|
name and this changeset's id were updated to match. -->
|
||||||
|
<include file="parts/0583_add_loan_originator_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0584_add_manage_survey_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- No permission code existed at all for the Sponsor DD GST/GL settings mapping before this -
|
||||||
|
the endpoint (POST /sponsordd/gl-mapping) was reachable by any authenticated user with no
|
||||||
|
permission check anywhere in the request path. This adds the one permission the endpoint now
|
||||||
|
requires, now that it is routed through the generic Maker-Checker command pipeline
|
||||||
|
(UpdateSponsorDdGlMappingCommandHandler). One code, not a CREATE/UPDATE pair: the underlying
|
||||||
|
write is a genuine upsert (m_sponsor_dd_gl_mapping is always a single row), so there is no
|
||||||
|
separate create step to give its own code to. -->
|
||||||
|
<changeSet id="0571_add_sponsor_dd_gl_mapping_permission" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SPONSORDDGLMAPPING'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="UPDATE_SPONSORDDGLMAPPING"/>
|
||||||
|
<column name="entity_name" value="SPONSORDDGLMAPPING"/>
|
||||||
|
<column name="action_name" value="UPDATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Granted to the same roles the sibling org-setup permissions (0511) grant to - Super user and
|
||||||
|
any manager-named role - since configuring the sponsor DD GL/GST mapping is a manager/admin
|
||||||
|
act, not a teller or cashier one. -->
|
||||||
|
<changeSet id="0571_grant_sponsor_dd_gl_mapping_permission" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM m_role r
|
||||||
|
CROSS JOIN m_permission p
|
||||||
|
WHERE p.code = 'UPDATE_SPONSORDDGLMAPPING'
|
||||||
|
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp
|
||||||
|
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- CREATE_SPONSORBANK and READ_SPONSORBANK already exist (seeded outside this changelog history -
|
||||||
|
see the drift noted when CREATE_SPONSORBANK was wired for Maker-Checker), but no UPDATE
|
||||||
|
permission for this entity has ever existed, so PUT /sponsorbanks/{id} had no permission code
|
||||||
|
to check anywhere in its request path. This adds it, seeded consistently with the existing
|
||||||
|
Sponsor Bank permissions (same grouping/entity_name, can_maker_checker off by default - an
|
||||||
|
administrator opts in via Configure Maker-Checker Tasks, same as CREATE_SPONSORBANK was). -->
|
||||||
|
<changeSet id="0572_add_sponsor_bank_update_permission" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SPONSORBANK'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="UPDATE_SPONSORBANK"/>
|
||||||
|
<column name="entity_name" value="SPONSORBANK"/>
|
||||||
|
<column name="action_name" value="UPDATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Granted to every role that already holds CREATE_SPONSORBANK, so whoever could create a sponsor
|
||||||
|
bank before this change can also still edit one after it - no role loses access it already
|
||||||
|
had. -->
|
||||||
|
<changeSet id="0572_grant_sponsor_bank_update_permission" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT rp.role_id, p.id
|
||||||
|
FROM m_role_permission rp
|
||||||
|
JOIN m_permission existing ON existing.id = rp.permission_id AND existing.code = 'CREATE_SPONSORBANK'
|
||||||
|
CROSS JOIN m_permission p
|
||||||
|
WHERE p.code = 'UPDATE_SPONSORBANK'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Lets a generated Form A be kept, so a filed return can be looked at again later exactly as it
|
||||||
|
was filed.
|
||||||
|
|
||||||
|
The summary columns 0581 added are enough to list saved returns, but not to reopen one: a
|
||||||
|
return is a statement, not five numbers, and reopening it has to show the same lines, the
|
||||||
|
same account breakdown and the same daily table that were on it when it was saved. Those
|
||||||
|
cannot be recomputed later and come out the same - a backdated journal entry, a changed GL
|
||||||
|
mapping or a corrected rate all move figures that were already reported. So the rendered
|
||||||
|
return is stored alongside the summary, and a saved report is served from that snapshot
|
||||||
|
rather than recomputed.
|
||||||
|
|
||||||
|
Keyed by (reporting date, form code) rather than by fortnight alone: Form 1 and Form 9 are
|
||||||
|
separate returns that can both be saved for dates in one month, and a bank re-running Form 1
|
||||||
|
for a different date within the same fortnight is saving a different statement, not
|
||||||
|
overwriting the earlier one.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0582-1-form-a-filing-report-json">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_form_a_filing"/>
|
||||||
|
<not><columnExists tableName="m_rbi_form_a_filing" columnName="report_json"/></not>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_rbi_form_a_filing">
|
||||||
|
<!-- The whole computed return as it was served when saved. TEXT rather than a typed
|
||||||
|
column set: the return's shape is the API's own, and mirroring it in columns would
|
||||||
|
mean a migration every time a line is added to the statement. -->
|
||||||
|
<column name="report_json" type="TEXT"/>
|
||||||
|
<!-- FORM_1 or FORM_9. Both are saved through the same screen and the same table. -->
|
||||||
|
<column name="form_code" type="VARCHAR(20)" defaultValue="FORM_1">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="office_id" type="BIGINT"/>
|
||||||
|
<!-- Denormalised from the snapshot so the list can show them without parsing the JSON. -->
|
||||||
|
<column name="period_start" type="DATE"/>
|
||||||
|
<column name="period_end" type="DATE"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0582-2-form-a-filing-rekey-by-date-and-form">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_form_a_filing"/>
|
||||||
|
<columnExists tableName="m_rbi_form_a_filing" columnName="form_code"/>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
0581 made the fortnight alone unique, which was right while a filing was one row per
|
||||||
|
fortnight. Saving a rendered return makes that wrong: Form 1 and Form 9 both save against
|
||||||
|
dates in the same fortnight and would collide, and re-saving Form 1 for a different
|
||||||
|
reporting date inside one fortnight is a second statement rather than a replacement.
|
||||||
|
|
||||||
|
Dropped and replaced rather than added alongside: leaving the old constraint in place
|
||||||
|
would keep rejecting exactly the rows this change exists to allow.
|
||||||
|
-->
|
||||||
|
<dropUniqueConstraint tableName="m_rbi_form_a_filing" constraintName="uk_rbi_form_a_filing_fortnight"/>
|
||||||
|
<addUniqueConstraint tableName="m_rbi_form_a_filing"
|
||||||
|
columnNames="form_code, reporting_date"
|
||||||
|
constraintName="uk_rbi_form_a_filing_form_date"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- The 5 LOAN_ORIGINATOR write permissions (CREATE/UPDATE/DELETE/ATTACH/DETACH_LOAN_ORIGINATOR)
|
||||||
|
were seeded with can_maker_checker=false and no corresponding "_CHECKER" permission rows.
|
||||||
|
AppUser.validateHasCheckerPermissionTo(permissionCode) (fineract-core/.../useradministration/
|
||||||
|
domain/AppUser.java) computes checkerPermissionName = permissionCode.toUpperCase() + "_CHECKER"
|
||||||
|
and requires the caller to hold either CHECKER_SUPER_USER or that exact permission code -
|
||||||
|
since m_role_permission.permission_id foreign-keys to m_permission(id), a "_CHECKER" row that
|
||||||
|
was never seeded can never be granted to any ordinary role. This means that even if
|
||||||
|
can_maker_checker were flipped on for these 5 permissions later, no non-super-user checker
|
||||||
|
could ever approve the resulting AWAITING_APPROVAL commands. This changeset only adds the
|
||||||
|
missing "_CHECKER" rows so the approval path is structurally ready; it deliberately does NOT
|
||||||
|
touch can_maker_checker on the base permissions (still false, unchanged). -->
|
||||||
|
<changeSet id="0583_add_loan_originator_checker_permissions" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_LOAN_ORIGINATOR_CHECKER'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="CREATE_LOAN_ORIGINATOR_CHECKER"/>
|
||||||
|
<column name="entity_name" value="LOAN_ORIGINATOR"/>
|
||||||
|
<column name="action_name" value="CREATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="UPDATE_LOAN_ORIGINATOR_CHECKER"/>
|
||||||
|
<column name="entity_name" value="LOAN_ORIGINATOR"/>
|
||||||
|
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="DELETE_LOAN_ORIGINATOR_CHECKER"/>
|
||||||
|
<column name="entity_name" value="LOAN_ORIGINATOR"/>
|
||||||
|
<column name="action_name" value="DELETE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="ATTACH_LOAN_ORIGINATOR_CHECKER"/>
|
||||||
|
<column name="entity_name" value="LOAN_ORIGINATOR"/>
|
||||||
|
<column name="action_name" value="ATTACH_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="DETACH_LOAN_ORIGINATOR_CHECKER"/>
|
||||||
|
<column name="entity_name" value="LOAN_ORIGINATOR"/>
|
||||||
|
<column name="action_name" value="DETACH_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Granted to whichever roles already hold the corresponding base LOAN_ORIGINATOR permission
|
||||||
|
(confirmed live: Branch Manager and Clerk hold all 5 base write permissions) - the same
|
||||||
|
"grant to roles that already hold the base permission" convention already used for
|
||||||
|
UPDATE_SPONSORBANK (0572_add_sponsor_bank_update_permission.xml). -->
|
||||||
|
<changeSet id="0583_grant_loan_originator_checker_permissions" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT rp.role_id, checker.id
|
||||||
|
FROM m_role_permission rp
|
||||||
|
JOIN m_permission base ON base.id = rp.permission_id
|
||||||
|
JOIN m_permission checker ON checker.code = base.code || '_CHECKER'
|
||||||
|
WHERE base.code IN ('CREATE_LOAN_ORIGINATOR', 'UPDATE_LOAN_ORIGINATOR', 'DELETE_LOAN_ORIGINATOR',
|
||||||
|
'ATTACH_LOAN_ORIGINATOR', 'DETACH_LOAN_ORIGINATOR')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Saves each fortnight's Form A at the close of that fortnight.
|
||||||
|
|
||||||
|
What a Form A reports cannot be recovered afterwards: a backdated journal entry, a re-mapped
|
||||||
|
GL account or a corrected rate all move figures that were already reported, so a fortnight
|
||||||
|
nobody saved is one whose filed statement no longer exists anywhere. Leaving that to someone
|
||||||
|
remembering to press Save is leaving the bank's own record of what it filed to chance.
|
||||||
|
|
||||||
|
The cron fires at 23:30 on the 15th and on the last day of each month - the two days a
|
||||||
|
fortnight closes under RBI's reckoning. Late enough that the day's postings are in, and the
|
||||||
|
job re-checks the date itself rather than trusting the schedule, so a mis-set cron cannot
|
||||||
|
file a fortnight that has not closed.
|
||||||
|
|
||||||
|
It saves as DRAFT and stops there. Checking, approving and submitting remain human acts; the
|
||||||
|
job exists so the figures survive, not so a return files itself. It also never overwrites a
|
||||||
|
fortnight already saved, by hand or by an earlier run - re-running must not replace a
|
||||||
|
statement somebody has reviewed.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0583-1-save-rbi-form-a-return-job">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Save RBI Form A Return'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="job">
|
||||||
|
<column name="name" value="Save RBI Form A Return"/>
|
||||||
|
<column name="display_name" value="Save RBI Form A Return"/>
|
||||||
|
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
|
||||||
|
<column name="short_name" value="RBIFRMA"/>
|
||||||
|
<!-- 23:30 on the 15th and on the last day of the month. "L" is Quartz's last-day-of-month
|
||||||
|
token, which is what makes one expression cover both 28th-of-February and 31st-of-
|
||||||
|
January without a month-by-month list. -->
|
||||||
|
<column name="cron_expression" value="0 30 23 15,L * ? *"/>
|
||||||
|
<column name="create_time" valueDate="${current_datetime}"/>
|
||||||
|
<column name="task_priority" valueNumeric="5"/>
|
||||||
|
<column name="group_name"/>
|
||||||
|
<column name="previous_run_start_time"/>
|
||||||
|
<column name="job_key" value="Save RBI Form A ReturnJobDetail1 _ DEFAULT"/>
|
||||||
|
<column name="initializing_errorlog"/>
|
||||||
|
<column name="is_active" valueBoolean="true"/>
|
||||||
|
<column name="currently_running" valueBoolean="false"/>
|
||||||
|
<column name="updates_allowed" valueBoolean="true"/>
|
||||||
|
<column name="scheduler_group" valueNumeric="1"/>
|
||||||
|
<column name="is_misfired" valueBoolean="false"/>
|
||||||
|
<column name="node_id" valueNumeric="1"/>
|
||||||
|
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- The Manage Surveys screen (SPM scorecards, spm.api.SpmApiResource) previously performed
|
||||||
|
create/update/activate/deactivate directly against SpmService with only a login check -
|
||||||
|
no permission check at any layer. These 4 write operations have now been wired into the
|
||||||
|
standard command pipeline (CommandWrapperBuilder.createSurvey/updateSurvey/activateSurvey/
|
||||||
|
deactivateSurvey -> CreateSurveyCommandHandler/UpdateSurveyCommandHandler/
|
||||||
|
ActivateSurveyCommandHandler/DeactivateSurveyCommandHandler, entity "SURVEY"), which requires
|
||||||
|
these permission rows to exist so PortfolioCommandSourceWritePlatformServiceImpl.logCommandSource()
|
||||||
|
can authorize the caller. Seeded with can_maker_checker=false: no prior maker-checker behaviour
|
||||||
|
existed for this screen, and enabling maker-checker is a business decision left for later. -->
|
||||||
|
<changeSet id="0584_add_manage_survey_permissions" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_SURVEY'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="CREATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="CREATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="UPDATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="UPDATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="ACTIVATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="ACTIVATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="DEACTIVATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="DEACTIVATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- No role previously held any permission for this screen (it had no permission check at all),
|
||||||
|
so there are no "roles that already hold the base permission" to copy from. Following the
|
||||||
|
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
|
||||||
|
Super user plus any manager-named role. -->
|
||||||
|
<changeSet id="0584_grant_manage_survey_permissions" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM m_role r
|
||||||
|
JOIN m_permission p ON p.code IN ('CREATE_SURVEY', 'UPDATE_SURVEY', 'ACTIVATE_SURVEY', 'DEACTIVATE_SURVEY')
|
||||||
|
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Gives Form 9 the assets SLR is actually maintained in.
|
||||||
|
|
||||||
|
The statutory return is a statement of "cash, gold and unencumbered approved securities" -
|
||||||
|
three classes, not one. The return could previously only read a single built-in role for
|
||||||
|
government securities, so a bank holding gold had nowhere to state it and a bank holding
|
||||||
|
several categories of approved security had to collapse them into one figure.
|
||||||
|
|
||||||
|
Three changes:
|
||||||
|
|
||||||
|
- A new role usage, SLR_ASSET, separate from MAINTAINED. The two returns admit different
|
||||||
|
assets: CRR is met in cash and balances with banks, SLR in cash, gold and approved
|
||||||
|
securities. One usage for both would let a head configured for Form 1 inflate the SLR
|
||||||
|
position, which is the error the return exists to detect.
|
||||||
|
|
||||||
|
- A Gold head, which did not exist at all.
|
||||||
|
|
||||||
|
- The three approved-securities heads seeded by 0581 move from MEMO to SLR_ASSET. They
|
||||||
|
were memo only because nothing counted them; now something does. The advances and
|
||||||
|
money-at-call heads deliberately stay MEMO - they are reported on Form A, and neither is
|
||||||
|
an SLR asset.
|
||||||
|
|
||||||
|
What this does NOT do is decide eligibility. A security that is pledged or encumbered is not
|
||||||
|
SLR-eligible however it is classified, and a GL balance cannot express that - it needs a
|
||||||
|
security master holding each holding's eligibility and encumbrance. Until that exists, these
|
||||||
|
heads report what the ledger holds and an administrator maps only what is genuinely eligible.
|
||||||
|
|
||||||
|
Every changeSet is guarded so this migration is a safe no-op where already applied.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0584-1-widen-role-usage-constraint">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
0575 constrained role_usage to four values. SLR_ASSET is a fifth, so the check has to be
|
||||||
|
widened before any row can carry it - otherwise the inserts below are rejected by the
|
||||||
|
constraint rather than by anything meaningful.
|
||||||
|
|
||||||
|
Dropped and recreated rather than altered: a CHECK constraint has no in-place edit, and
|
||||||
|
recreating it keeps the guard rather than quietly leaving the column unconstrained.
|
||||||
|
-->
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage;
|
||||||
|
ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage
|
||||||
|
CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'MEMO'));
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0584-2-seed-gold-head">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key = 'FORMA_SLR_GOLD'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORMA_SLR_GOLD"/>
|
||||||
|
<column name="label" value="Gold held in India"/>
|
||||||
|
<column name="description" value="Gold held in India, valued as the applicable rules require. Counts toward SLR maintained on Form 9. Map the GL that carries the gold holding - the return reads its balance, so that balance must already be at the valuation the return is to report."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="SLR_ASSET"/>
|
||||||
|
<column name="position" valueNumeric="210"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0584-3-securities-count-toward-slr">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="3">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role
|
||||||
|
WHERE config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT',
|
||||||
|
'FORMA_INV_OTHER_APPROVED')
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
Approved securities are what SLR is principally maintained in, so they count rather than
|
||||||
|
merely appear. Advances and money at call are left MEMO: both are reported on Form A and
|
||||||
|
neither is an SLR asset, and promoting them here would overstate the position.
|
||||||
|
-->
|
||||||
|
<update tableName="m_rbi_reserve_role">
|
||||||
|
<column name="role_usage" value="SLR_ASSET"/>
|
||||||
|
<where>
|
||||||
|
config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', 'FORMA_INV_OTHER_APPROVED')
|
||||||
|
AND role_usage = 'MEMO'
|
||||||
|
</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- LoanCOBCatchUpApiResource.executeLoanCOBCatchUp() (POST /v1/loans/catch-up) previously had no
|
||||||
|
permission check at all. A validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP") check was added
|
||||||
|
directly (not full CommandWrapper/maker-checker wiring, since this is a job-execution trigger,
|
||||||
|
not a CRUD operation on a persisted entity - matching the existing EXECUTE_INLINE_JOB precedent),
|
||||||
|
which requires this permission row to exist. can_maker_checker=false: this closes the
|
||||||
|
authorization gap only, it does not introduce maker-checker for job execution. -->
|
||||||
|
<changeSet id="0585_add_loan_cob_catchup_permission" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'EXECUTE_LOANCOBCATCHUP'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="organisation"/>
|
||||||
|
<column name="code" value="EXECUTE_LOANCOBCATCHUP"/>
|
||||||
|
<column name="entity_name" value="LOANCOBCATCHUP"/>
|
||||||
|
<column name="action_name" value="EXECUTE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- No role previously held any permission for this endpoint (it had no permission check at all),
|
||||||
|
so there are no "roles that already hold the base permission" to copy from. Following the
|
||||||
|
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
|
||||||
|
Super user plus any manager-named role. -->
|
||||||
|
<changeSet id="0585_grant_loan_cob_catchup_permission" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM m_role r
|
||||||
|
JOIN m_permission p ON p.code = 'EXECUTE_LOANCOBCATCHUP'
|
||||||
|
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- The Admin -> System maker-checker audit found 8 base permissions whose "_CHECKER" counterpart
|
||||||
|
row was never seeded (AppUser.validateHasCheckerPermissionTo() computes
|
||||||
|
checkerPermissionName = code.toUpperCase() + "_CHECKER" and requires the approver to hold
|
||||||
|
either CHECKER_SUPER_USER or that exact permission code - see the identical issue already
|
||||||
|
fixed for LOAN_ORIGINATOR in 0583_add_loan_originator_checker_permissions.xml). Without these
|
||||||
|
rows, no ordinary role can ever be granted checker rights for these operations, and
|
||||||
|
UPDATE_BATCH_BUSINESS_STEP (Manage Jobs - COB Workflow Step Reorder) is the one permission in
|
||||||
|
all of Admin -> System that already has can_maker_checker=true today, making its missing
|
||||||
|
checker row a live gap, not merely a structural one. All 8 rows are seeded with
|
||||||
|
can_maker_checker=false: this changeset only makes the approval path structurally ready, it
|
||||||
|
does not itself enable maker-checker on any base permission.
|
||||||
|
|
||||||
|
For CREATE/UPDATE/DELETE_ACCOUNTNUMBERFORMAT_CHECKER, entity_name is set to
|
||||||
|
"ACCOUNTNUMBERFORMAT" for all three (matching CREATE_ACCOUNTNUMBERFORMAT and
|
||||||
|
UPDATE_ACCOUNTNUMBERFORMAT). The pre-existing base permission DELETE_ACCOUNTNUMBERFORMAT has
|
||||||
|
entity_name="HOOK" (a pre-existing data bug, confirmed via read-only query, left untouched -
|
||||||
|
fixing an existing permission row's value was out of scope for this change). This checker row
|
||||||
|
deliberately uses the correct entity name rather than propagating that bug. -->
|
||||||
|
<changeSet id="0586_add_system_module_checker_permissions" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_ACCOUNTNUMBERFORMAT_CHECKER'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="CREATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
|
||||||
|
<column name="action_name" value="CREATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="UPDATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
|
||||||
|
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="DELETE_ACCOUNTNUMBERFORMAT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
|
||||||
|
<column name="action_name" value="DELETE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="infrastructure"/>
|
||||||
|
<column name="code" value="CREATE_ENTITYMAPPING_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ENTITYMAPPING"/>
|
||||||
|
<column name="action_name" value="CREATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="infrastructure"/>
|
||||||
|
<column name="code" value="UPDATE_ENTITYMAPPING_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ENTITYMAPPING"/>
|
||||||
|
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="infrastructure"/>
|
||||||
|
<column name="code" value="DELETE_ENTITYMAPPING_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ENTITYMAPPING"/>
|
||||||
|
<column name="action_name" value="DELETE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="organisation"/>
|
||||||
|
<column name="code" value="CREATE_USERAGREEMENT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="USERAGREEMENT"/>
|
||||||
|
<column name="action_name" value="CREATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="organisation"/>
|
||||||
|
<column name="code" value="UPDATE_BATCH_BUSINESS_STEP_CHECKER"/>
|
||||||
|
<column name="entity_name" value="BATCH_BUSINESS_STEP"/>
|
||||||
|
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Granted to whichever roles already hold the corresponding base permission - the same
|
||||||
|
"grant to roles that already hold the base permission" convention used in
|
||||||
|
0583_add_loan_originator_checker_permissions.xml. -->
|
||||||
|
<changeSet id="0586_grant_system_module_checker_permissions" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT rp.role_id, checker.id
|
||||||
|
FROM m_role_permission rp
|
||||||
|
JOIN m_permission base ON base.id = rp.permission_id
|
||||||
|
JOIN m_permission checker ON checker.code = base.code || '_CHECKER'
|
||||||
|
WHERE base.code IN ('CREATE_ACCOUNTNUMBERFORMAT', 'UPDATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
|
||||||
|
'CREATE_ENTITYMAPPING', 'UPDATE_ENTITYMAPPING', 'DELETE_ENTITYMAPPING',
|
||||||
|
'CREATE_USERAGREEMENT', 'UPDATE_BATCH_BUSINESS_STEP')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
2033 (savings module changelog) was meant to add the OD-scheme columns from 0577
|
||||||
|
(m_current_product) onto the pending-edit shadow table too, so an approved Current product edit
|
||||||
|
doesn't silently drop them. It guards on tableExists("m_savings_product_temp"), with an "s",
|
||||||
|
but the table 0369 actually created is "m_saving_product_temp" (singular, no "s"). That guard has
|
||||||
|
therefore never once matched, on any tenant: onFail="MARK_RAN" records 2033 as applied without
|
||||||
|
ever running its addColumn, so the columns were never added anywhere, and any edit to a Current
|
||||||
|
product with OD fields set fails the moment it reaches the pending-edit table ("column
|
||||||
|
od_excess_allowed does not exist"). Left 2033 itself untouched, since rewriting an already-applied
|
||||||
|
changeset's history is the wrong fix, and repeats it here against the table name that's actually
|
||||||
|
real, guarded the same way so it's a no-op wherever a hand patch already added these.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet author="CBS" id="0587-1-fix-saving-product-temp-od-min-limit">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_saving_product_temp"/>
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_saving_product_temp" columnName="od_min_limit"/>
|
||||||
|
</not>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_saving_product_temp">
|
||||||
|
<column name="od_min_limit" type="DECIMAL(19,6)"/>
|
||||||
|
<column name="od_security_type" type="VARCHAR(20)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="CBS" id="0587-2-fix-saving-product-temp-od-excess-drawing">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_saving_product_temp"/>
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_saving_product_temp" columnName="od_excess_allowed"/>
|
||||||
|
</not>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_saving_product_temp">
|
||||||
|
<column name="od_excess_allowed" type="BOOLEAN"/>
|
||||||
|
<column name="od_excess_limit_percent" type="DECIMAL(19,6)"/>
|
||||||
|
<column name="od_penal_rate" type="DECIMAL(19,6)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
|
||||||
|
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
|
||||||
|
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
|
||||||
|
dev database - none of that was ever captured by a migration, so a fresh/other environment
|
||||||
|
restored from this changelog history would come up with every one of them still at its
|
||||||
|
originally-seeded false value. Every code here was independently confirmed, via read-only
|
||||||
|
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
|
||||||
|
= false and currently true in the running dev DB with no existing changeset already covering
|
||||||
|
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
|
||||||
|
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
|
||||||
|
|
||||||
|
This changeset only ever moves can_maker_checker from false to true for these exact codes -
|
||||||
|
entity_name, action_name, grouping and every other column are left exactly as their origin
|
||||||
|
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
|
||||||
|
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
|
||||||
|
touched: those already exist for every code below (added earlier by 0583/0586, or by base
|
||||||
|
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
|
||||||
|
|
||||||
|
Grouped by the screen each permission belongs to:
|
||||||
|
- Currency Configuration: UPDATE_CURRENCY
|
||||||
|
- Manage Funds: CREATE_FUND
|
||||||
|
- Working Days: UPDATE_WORKINGDAYS
|
||||||
|
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
|
||||||
|
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
|
||||||
|
- Branch Profile: UPDATE_BRANCHPROFILE
|
||||||
|
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
|
||||||
|
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
|
||||||
|
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
|
||||||
|
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
|
||||||
|
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
|
||||||
|
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
|
||||||
|
- Manage User Agreement: CREATE_USERAGREEMENT
|
||||||
|
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
|
||||||
|
|
||||||
|
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
|
||||||
|
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
|
||||||
|
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
|
||||||
|
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
|
||||||
|
Survey permissions (confirmed still false - never actually toggled on)
|
||||||
|
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
|
||||||
|
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
|
||||||
|
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
|
||||||
|
Organization or Admin -> System - out of scope for this change and left as pre-existing
|
||||||
|
drift for a separate decision. -->
|
||||||
|
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
|
||||||
|
<update tableName="m_permission">
|
||||||
|
<column name="can_maker_checker" valueBoolean="true"/>
|
||||||
|
<where>code IN (
|
||||||
|
'UPDATE_CURRENCY',
|
||||||
|
'CREATE_FUND',
|
||||||
|
'UPDATE_WORKINGDAYS',
|
||||||
|
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
|
||||||
|
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
|
||||||
|
'UPDATE_BRANCHPROFILE',
|
||||||
|
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
|
||||||
|
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
|
||||||
|
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
|
||||||
|
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
|
||||||
|
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
|
||||||
|
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
|
||||||
|
'CREATE_USERAGREEMENT',
|
||||||
|
'UPDATE_BATCH_BUSINESS_STEP'
|
||||||
|
)</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+12
-6
@@ -116,7 +116,11 @@ public class CommandSourceServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
|
public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
|
||||||
|
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
|
||||||
|
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
|
||||||
|
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
|
||||||
|
// replaces the old bypass this test used to assert.
|
||||||
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
||||||
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
||||||
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
||||||
@@ -125,6 +129,8 @@ public class CommandSourceServiceTest {
|
|||||||
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
||||||
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
||||||
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
||||||
|
Mockito.when(commandSource.getId()).thenReturn(300L);
|
||||||
|
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
|
||||||
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
||||||
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
||||||
|
|
||||||
@@ -132,12 +138,12 @@ public class CommandSourceServiceTest {
|
|||||||
.withCommandId(1L).build();
|
.withCommandId(1L).build();
|
||||||
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
||||||
|
|
||||||
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
|
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
|
||||||
commandSource, superUser, false);
|
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
|
||||||
|
});
|
||||||
|
|
||||||
Assertions.assertNotNull(result);
|
Mockito.verify(commandSource).markAsAwaitingApproval();
|
||||||
Mockito.verify(commandSource).markAsChecked(superUser);
|
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
|
||||||
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+13
-8
@@ -105,12 +105,15 @@ public interface DepositAccountWritePlatformService {
|
|||||||
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
|
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credits gap-period interest (simple interest, at the linked Savings/Current account's product
|
* Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto
|
||||||
* rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning
|
* that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has
|
||||||
* zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the
|
* passed; a no-op, returning zero, if still within it. Paid to the linked account because it was
|
||||||
* generic `payment` command (the same one the account's own Account Actions menu uses), so every
|
* earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it
|
||||||
* closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit}
|
* renews from its real maturity amount regardless of which closure type follows. Called as a
|
||||||
* itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
|
* pre-step before the FD Dashboard's Renew action submits the generic `payment` command (the same
|
||||||
|
* one the account's own Account Actions menu uses), so every closure type is available from the
|
||||||
|
* Dashboard, not just the two {@link #renewMaturedFixedDeposit} itself supports. Returns
|
||||||
|
* {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
|
||||||
*/
|
*/
|
||||||
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
|
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
|
||||||
|
|
||||||
@@ -118,8 +121,10 @@ public interface DepositAccountWritePlatformService {
|
|||||||
* Read-only preview of what renewing this matured FD right now would do -- shown in the FD
|
* Read-only preview of what renewing this matured FD right now would do -- shown in the FD
|
||||||
* Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns
|
* Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns
|
||||||
* isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace,
|
* isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace,
|
||||||
* currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountRate,
|
* currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo,
|
||||||
* gapInterestAmount and projectedBalance (the balance the new term would start from).
|
* linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and
|
||||||
|
* projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually
|
||||||
|
* renews).
|
||||||
*/
|
*/
|
||||||
Map<String, Object> previewFdRenewal(Long fdAccountId);
|
Map<String, Object> previewFdRenewal(Long fdAccountId);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user