implemented a Maker-Checker workflow for Products, with approval-based updates and product locking to prevent concurrent edits

This commit is contained in:
keshav898966
2026-07-03 15:15:09 +05:30
parent 99c153830c
commit b0bfa386ce
71 changed files with 5031 additions and 26 deletions
@@ -159,6 +159,9 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private Map<String, Object> depositLimitKyc;
@Getter @Setter private Map<String, List<Map<String, Object>>> otherGlMappingsMap;
// True when a row exists in m_saving_product_temp with status = PENDING for this product - that row is the lock.
@Getter @Setter private Boolean isPendingApproval;
public static SavingsProductData template(final CurrencyData currency, final EnumOptionData interestCompoundingPeriodType,
final EnumOptionData interestPostingPeriodType, final EnumOptionData interestCalculationType,
final EnumOptionData interestCalculationDaysInYearType, final EnumOptionData accountingRule,
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/**
* Read-side view of a single pending Savings Product edit row (m_savings_product_temp), for the Approvals ->
* Pending Savings Product Edits screen.
*/
@Getter
@Setter
public class SavingsProductTempData implements Serializable {
private Long id;
private Long savingsProductId;
private String savingsProductName;
private Long makerId;
private String makerUsername;
private Long checkerId;
private String checkerUsername;
private LocalDateTime createdOn;
private LocalDateTime checkedOn;
private String status;
private String rejectReason;
private Map<String, Object> proposedChanges;
public SavingsProductTempData() {}
}
@@ -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.currentproduct.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentProductEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public CurrentProductEditPendingApprovalException(final Long productId) {
super("error.msg.currentproduct.edit.pending.approval",
"Current product with identifier " + productId + " already has an edit pending approval", productId);
}
}
@@ -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.currentproduct.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentProductNoPendingEditException extends AbstractPlatformDomainRuleException {
public CurrentProductNoPendingEditException(final Long productId) {
super("error.msg.currentproduct.no.pending.edit",
"Current product with identifier " + productId + " has no edit pending approval", productId);
}
}
@@ -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.currentproduct.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentProductSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public CurrentProductSelfApprovalNotAllowedException(final Long productId) {
super("error.msg.currentproduct.self.approval.not.allowed",
"The maker of the pending edit for current product " + productId + " cannot also approve or reject it", productId);
}
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.currentproduct.service;
import java.util.Collection;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
public interface CurrentProductReadPlatformService {
@@ -28,4 +29,8 @@ public interface CurrentProductReadPlatformService {
Collection<SavingsProductData> retrieveAllForLookup();
SavingsProductData retrieveOne(Long productId);
Collection<SavingsProductTempData> retrieveAllPendingEdits();
SavingsProductTempData retrievePendingEdit(Long productId);
}
@@ -42,7 +42,9 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilit
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimit;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
import org.apache.fineract.portfolio.currentproduct.exception.CurrentProductNoPendingEditException;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.apache.fineract.portfolio.tax.data.TaxGroupData;
@@ -147,6 +149,55 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
}
}
private static final String PENDING_EDIT_SCHEMA = "spt.id as tempId, spt.product_id as savingsProductId, "
+ "cp.name as savingsProductName, spt.maker_id as makerId, maker.username as makerUsername, "
+ "spt.checker_id as checkerId, checker.username as checkerUsername, spt.created_on as createdOn, "
+ "spt.checked_on as checkedOn, spt.status as status, spt.reject_reason as rejectReason, "
+ "spt.proposed_payload_json as proposedPayloadJson "
+ "from m_savings_product_temp spt "
+ "join m_current_product cp on cp.id = spt.product_id "
+ "join m_appuser maker on maker.id = spt.maker_id "
+ "left join m_appuser checker on checker.id = spt.checker_id "
+ "where spt.product_type = 'CURRENT' ";
private static final RowMapper<SavingsProductTempData> PENDING_EDIT_MAPPER = (rs, rowNum) -> {
final SavingsProductTempData data = new SavingsProductTempData();
data.setId(rs.getLong("tempId"));
data.setSavingsProductId(rs.getLong("savingsProductId"));
data.setSavingsProductName(rs.getString("savingsProductName"));
data.setMakerId(rs.getLong("makerId"));
data.setMakerUsername(rs.getString("makerUsername"));
data.setCheckerId(JdbcSupport.getLong(rs, "checkerId"));
data.setCheckerUsername(rs.getString("checkerUsername"));
data.setCreatedOn(rs.getTimestamp("createdOn") == null ? null : rs.getTimestamp("createdOn").toLocalDateTime());
data.setCheckedOn(rs.getTimestamp("checkedOn") == null ? null : rs.getTimestamp("checkedOn").toLocalDateTime());
data.setStatus(rs.getString("status"));
data.setRejectReason(rs.getString("rejectReason"));
final String proposedPayloadJson = rs.getString("proposedPayloadJson");
if (proposedPayloadJson != null) {
data.setProposedChanges(GSON.fromJson(proposedPayloadJson, new TypeToken<Map<String, Object>>() {}.getType()));
}
return data;
};
@Override
public Collection<SavingsProductTempData> retrieveAllPendingEdits() {
this.context.authenticatedUser();
final String sql = "select " + PENDING_EDIT_SCHEMA + "and spt.status = 'PENDING' order by spt.created_on desc";
return this.jdbcTemplate.query(sql, PENDING_EDIT_MAPPER);
}
@Override
public SavingsProductTempData retrievePendingEdit(final Long productId) {
this.context.authenticatedUser();
final String sql = "select " + PENDING_EDIT_SCHEMA + "and spt.status = 'PENDING' and spt.product_id = ?";
try {
return this.jdbcTemplate.queryForObject(sql, PENDING_EDIT_MAPPER, new Object[] { productId }); // NOSONAR
} catch (final EmptyResultDataAccessException e) {
throw new CurrentProductNoPendingEditException(productId);
}
}
private static final class CurrentProductMapper implements RowMapper<SavingsProductData> {
private final String schemaSql;
@@ -197,7 +248,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
sqlBuilder.append("cp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing ");
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("exists(select 1 from m_savings_product_temp spt where spt.product_id = cp.id "
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
sqlBuilder.append("from m_current_product cp ");
sqlBuilder.append("join m_currency curr on curr.code = cp.currency_code ");
@@ -321,6 +374,7 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
productData.setValidForPeriod(rs.getString("validForPeriod"));
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return productData;
}
@@ -28,4 +28,21 @@ public interface CurrentProductWritePlatformService {
CommandProcessingResult update(Long productId, JsonCommand command);
CommandProcessingResult delete(Long productId);
/**
* Submits an edit for approval instead of applying it directly. Fails if a pending edit already exists for this
* product (that pending row is itself the lock).
*/
CommandProcessingResult submitEditForApproval(Long productId, JsonCommand command);
/**
* Replays the stored pending edit through the normal update path and marks it approved. Fails if the approving
* user is the same user who submitted the edit.
*/
CommandProcessingResult approveEdit(Long productId);
/**
* Discards the pending edit. Fails if the rejecting user is the same user who submitted the edit.
*/
CommandProcessingResult rejectEdit(Long productId, String rejectReason);
}
@@ -31,6 +31,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
@@ -38,6 +41,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.currentproduct.data.CurrentProductDataValidator;
@@ -50,10 +55,16 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilit
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimit;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.currentproduct.exception.CurrentProductEditPendingApprovalException;
import org.apache.fineract.portfolio.currentproduct.exception.CurrentProductNoPendingEditException;
import org.apache.fineract.portfolio.currentproduct.exception.CurrentProductSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.tax.domain.TaxGroup;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataAccessException;
import org.springframework.transaction.annotation.Transactional;
@@ -69,6 +80,9 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
private final CurrentProductFacilityConfigRepository facilityConfigRepository;
private final CurrentProductFacilityLimitRepository facilityLimitRepository;
private final CurrentProductDepositLimitKycRepository depositLimitKycRepository;
private final ProductPendingEditRepository productPendingEditRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
@@ -251,6 +265,124 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
}
}
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long productId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final CurrentProduct product = this.currentProductRepository.findById(productId)
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
if (this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_CURRENT, productId, ProductPendingEdit.STATUS_PENDING)
.isPresent()) {
throw new CurrentProductEditPendingApprovalException(productId);
}
this.fromApiJsonDataValidator.validateForUpdate(command.json(), product);
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_CURRENT, productId, maker, command.json());
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("CURRENTPRODUCT") //
.resourceGetUrl("/currentproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
.build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long productId) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_CURRENT, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentProductSelfApprovalNotAllowedException(productId);
}
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(productId,
this.fromApiJsonHelper.parse(pendingEdit.getProposedPayloadJson()), this.fromApiJsonHelper);
final CommandProcessingResult result = update(productId, replayedCommand);
pendingEdit.markApproved(checker);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsChecked(checker);
auditEntry.updateForAudit(result);
this.commandSourceRepository.saveAndFlush(auditEntry);
return result;
}
private CommandSource findOrCreateAuditEntry(final ProductPendingEdit pendingEdit, final Long productId) {
if (pendingEdit.getCommandSourceId() != null) {
final java.util.Optional<CommandSource> existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
if (existing.isPresent()) {
return existing.get();
}
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("CURRENTPRODUCT") //
.resourceGetUrl("/currentproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(pendingEdit.getProposedPayloadJson()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return auditEntry;
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long productId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_CURRENT, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentProductSelfApprovalNotAllowedException(productId);
}
pendingEdit.markRejected(checker, rejectReason);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsRejected(checker);
this.commandSourceRepository.saveAndFlush(auditEntry);
return new CommandProcessingResultBuilder() //
.withEntityId(productId) //
.build();
}
@Transactional
@Override
public CommandProcessingResult delete(final Long productId) {
@@ -19,7 +19,10 @@
package org.apache.fineract.portfolio.currentproduct.starter;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.currentproduct.data.CurrentProductDataValidator;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductAssembler;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDepositLimitKycRepository;
@@ -56,9 +59,13 @@ public class CurrentProductConfiguration {
ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService,
CurrentProductFacilityConfigRepository currentFacilityConfigRepository,
CurrentProductFacilityLimitRepository currentFacilityLimitRepository,
CurrentProductDepositLimitKycRepository currentDepositLimitKycRepository) {
CurrentProductDepositLimitKycRepository currentDepositLimitKycRepository,
ProductPendingEditRepository productPendingEditRepository,
FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
currentFacilityLimitRepository, currentDepositLimitKycRepository);
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository, fromApiJsonHelper,
commandSourceRepository);
}
}
@@ -37,20 +37,26 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.UriInfo;
import java.util.Collection;
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.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.ResourceNotFoundException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.products.constants.ProductsApiConstants;
import org.apache.fineract.portfolio.products.data.ProductData;
import org.apache.fineract.portfolio.products.service.ShareProductReadPlatformService;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.shareproducts.service.ShareProductWritePlatformService;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
@@ -61,11 +67,26 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class ProductsApiResource {
private static final String SHARE_PRODUCT_TYPE = "share";
/**
* These product types have their own dedicated endpoints (/savingsproducts/{id}, /currentproducts/{id},
* /fixeddepositproducts/{id}, /recurringdepositproducts/{id}) which correctly route edits through
* submitEditForApproval - the pending-edit row is the lock. This generic /v1/products/{type}/{productId} PUT
* falls through to the native command-processing pipeline for any type not special-cased above, which would
* bypass that lock entirely for these types. The Angular frontend never calls this generic path for them (only
* for "share"), so blocking them here closes the bypass without removing any real functionality.
*/
private static final java.util.Set<String> PRODUCT_TYPES_WITH_DEDICATED_LOCKED_ENDPOINT = java.util.Set.of("savings", "current",
"fixeddeposit", "recurringdeposit");
private final ApplicationContext applicationContext;
private final ApiRequestParameterHelper apiRequestParameterHelper;
private final DefaultToApiJsonSerializer<ProductData> toApiJsonSerializer;
private final PlatformSecurityContext platformSecurityContext;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final ShareProductWritePlatformService shareProductWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<SavingsProductTempData> tempDataApiJsonSerializer;
@GET
@Path("template")
@@ -171,9 +192,100 @@ public class ProductsApiResource {
@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.platformSecurityContext.authenticatedUser();
if (SHARE_PRODUCT_TYPE.equalsIgnoreCase(productType)) {
// Bypasses the native command-source/maker-checker pipeline by design, same as Savings/Current
// Products: edits are held in m_savings_product_temp (a pending row there is itself the lock) and
// only applied to m_share_product on approval - see the pending/approve/reject endpoints below.
final JsonCommand command = JsonCommand.fromJsonElement(productId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.shareProductWritePlatformService.submitEditForApproval(productId, command);
return this.toApiJsonSerializer.serialize(result);
}
if (PRODUCT_TYPES_WITH_DEDICATED_LOCKED_ENDPOINT.contains(productType.toLowerCase(java.util.Locale.ROOT))) {
throw new ResourceNotFoundException("error.msg.product.generic.update.not.supported",
"Updating a " + productType + " product through this generic endpoint is not supported - use its dedicated endpoint",
new Object[] { productType });
}
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateProduct(productType, productId)
.withJson(apiRequestBodyAsJson).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Share Products pending edit approval")
public String retrieveAllPending(@PathParam("type") @Parameter(description = "type") final String productType,
@Context final UriInfo uriInfo) {
this.platformSecurityContext.authenticatedUser();
try {
String serviceName = productType + ProductsApiConstants.READPLATFORM_NAME;
ShareProductReadPlatformService service = (ShareProductReadPlatformService) this.applicationContext.getBean(serviceName);
final Collection<SavingsProductTempData> pendingEdits = service.retrieveAllPendingEdits();
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdits);
} catch (BeansException e) {
throw new ResourceNotFoundException(e);
}
}
@GET
@Path("{productId}/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending edit for a Share Product")
public String retrievePending(@PathParam("type") @Parameter(description = "type") final String productType,
@PathParam("productId") @Parameter(description = "productId") final Long productId, @Context final UriInfo uriInfo) {
this.platformSecurityContext.authenticatedUser();
try {
String serviceName = productType + ProductsApiConstants.READPLATFORM_NAME;
ShareProductReadPlatformService service = (ShareProductReadPlatformService) this.applicationContext.getBean(serviceName);
final SavingsProductTempData pendingEdit = service.retrievePendingEdit(productId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdit);
} catch (BeansException e) {
throw new ResourceNotFoundException(e);
}
}
@POST
@Path("{productId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Share Product edit")
public String approvePending(@PathParam("type") @Parameter(description = "type") final String productType,
@PathParam("productId") @Parameter(description = "productId") final Long productId) {
if (this.platformSecurityContext.authenticatedUser().hasNotPermissionForAnyOf("APPROVE_SHAREPRODUCT")) {
throw new NoAuthorizationException("User has no authority to approve share products");
}
final CommandProcessingResult result = this.shareProductWritePlatformService.approveEdit(productId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{productId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Share Product edit")
public String rejectPending(@PathParam("type") @Parameter(description = "type") final String productType,
@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.platformSecurityContext.authenticatedUser().hasNotPermissionForAnyOf("REJECT_SHAREPRODUCT")) {
throw new NoAuthorizationException("User has no authority to reject share products");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.shareProductWritePlatformService.rejectEdit(productId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -22,6 +22,7 @@ import java.util.Collection;
import java.util.Set;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.products.data.ProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
public interface ShareProductReadPlatformService {
@@ -34,4 +35,8 @@ public interface ShareProductReadPlatformService {
Set<String> getResponseDataParams();
Collection<ProductData> retrieveAllForLookup();
Collection<SavingsProductTempData> retrieveAllPendingEdits();
SavingsProductTempData retrievePendingEdit(Long productId);
}
@@ -53,10 +53,13 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDoma
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService;
@@ -71,7 +74,9 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYea
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.apache.fineract.portfolio.tax.data.TaxGroupData;
@@ -101,6 +106,9 @@ public class CurrentProductsApiResource {
private final ConfigurationDomainService configurationDomainService;
private final CodeValueReadPlatformService codeValueReadPlatformService;
private final JdbcTemplate jdbcTemplate;
private final CurrentProductWritePlatformService currentProductWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<SavingsProductTempData> tempDataApiJsonSerializer;
@POST
@@ -122,9 +130,72 @@ public class CurrentProductsApiResource {
@ApiResponse(responseCode = "200", description = "OK")
public String update(@PathParam("productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrentProduct(productId).withJson(apiRequestBodyAsJson)
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// Bypasses the native command-source/maker-checker pipeline by design: edits are held in
// m_savings_product_temp (a pending row there is itself the lock) and only applied to m_current_product on
// approval - see /currentproducts/{productId}/approve and /reject below.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME);
final JsonCommand command = JsonCommand.fromJsonElement(productId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.currentProductWritePlatformService.submitEditForApproval(productId, command);
return toApiJsonSerializer.serialize(result);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Current Products pending edit approval")
public String retrieveAllPending(@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME);
final Collection<SavingsProductTempData> pendingEdits = this.savingProductReadPlatformService.retrieveAllPendingEdits();
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{productId}/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending edit for a Current Product")
public String retrievePending(@PathParam("productId") final Long productId, @Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME);
final SavingsProductTempData pendingEdit = this.savingProductReadPlatformService.retrievePendingEdit(productId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{productId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Current Product edit")
public String approvePending(@PathParam("productId") final Long productId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVE_CURRENTPRODUCT")) {
throw new NoAuthorizationException("User has no authority to approve current products");
}
final CommandProcessingResult result = this.currentProductWritePlatformService.approveEdit(productId);
return toApiJsonSerializer.serialize(result);
}
@POST
@Path("{productId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Current Product edit")
public String rejectPending(@PathParam("productId") final Long productId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECT_CURRENTPRODUCT")) {
throw new NoAuthorizationException("User has no authority to reject current products");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.currentProductWritePlatformService.rejectEdit(productId, rejectReason);
return toApiJsonSerializer.serialize(result);
}
@@ -53,14 +53,17 @@ 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.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import java.util.Locale;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService;
@@ -78,8 +81,10 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYea
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.data.FixedDepositProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.savings.service.DepositProductReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositsDropdownReadPlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositProductWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.apache.fineract.portfolio.tax.data.TaxGroupData;
@@ -116,6 +121,9 @@ public class FixedDepositProductsApiResource {
private final TaxReadPlatformService taxReadPlatformService;
private final CodeValueReadPlatformService codeValueReadPlatformService;
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private final FixedDepositProductWritePlatformService fixedDepositProductWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<SavingsProductTempData> tempDataApiJsonSerializer;
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@@ -151,15 +159,81 @@ public class FixedDepositProductsApiResource {
public String update(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateFixedDepositProduct(productId)
.withJson(apiRequestBodyAsJson).build();
// Bypasses the native command-source/maker-checker pipeline by design: edits are held in
// m_savings_product_temp (a pending row there is itself the lock) and only applied to m_savings_product on
// approval - see /fixeddepositproducts/{productId}/approve and /reject below.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
final JsonCommand command = JsonCommand.fromJsonElement(productId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.fixedDepositProductWritePlatformService.submitEditForApproval(productId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Fixed Deposit Products pending edit approval")
public String retrieveAllPending(@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME);
final Collection<SavingsProductTempData> pendingEdits = this.depositProductReadPlatformService
.retrieveAllPendingEdits(DepositAccountType.FIXED_DEPOSIT);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{productId}/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending edit for a Fixed Deposit Product")
public String retrievePending(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME);
final SavingsProductTempData pendingEdit = this.depositProductReadPlatformService
.retrievePendingEdit(DepositAccountType.FIXED_DEPOSIT, productId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{productId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Fixed Deposit Product edit")
public String approvePending(@PathParam("productId") @Parameter(description = "productId") final Long productId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVE_FIXEDDEPOSITPRODUCT")) {
throw new NoAuthorizationException("User has no authority to approve fixed deposit products");
}
final CommandProcessingResult result = this.fixedDepositProductWritePlatformService.approveEdit(productId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{productId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Fixed Deposit Product edit")
public String rejectPending(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECT_FIXEDDEPOSITPRODUCT")) {
throw new NoAuthorizationException("User has no authority to reject fixed deposit products");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.fixedDepositProductWritePlatformService.rejectEdit(productId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@@ -55,10 +55,13 @@ 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.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService;
@@ -85,8 +88,10 @@ import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializer;
import java.time.LocalDate;
import org.apache.fineract.portfolio.savings.data.RecurringDepositProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.savings.service.DepositProductReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositsDropdownReadPlatformService;
import org.apache.fineract.portfolio.savings.service.RecurringDepositProductWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.apache.fineract.portfolio.tax.data.TaxGroupData;
@@ -126,6 +131,9 @@ public class RecurringDepositProductsApiResource {
.registerTypeAdapter(LocalDate.class,
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
.create();
private final RecurringDepositProductWritePlatformService recurringDepositProductWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<SavingsProductTempData> tempDataApiJsonSerializer;
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@@ -158,15 +166,82 @@ public class RecurringDepositProductsApiResource {
public String update(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateRecurringDepositProduct(productId)
.withJson(apiRequestBodyAsJson).build();
// Bypasses the native command-source/maker-checker pipeline by design: edits are held in
// m_savings_product_temp (a pending row there is itself the lock) and only applied to m_savings_product on
// approval - see /recurringdepositproducts/{productId}/approve and /reject below.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.RECURRING_DEPOSIT_PRODUCT_RESOURCE_NAME);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
final JsonCommand command = JsonCommand.fromJsonElement(productId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.recurringDepositProductWritePlatformService.submitEditForApproval(productId,
command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Recurring Deposit Products pending edit approval")
public String retrieveAllPending(@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_PRODUCT_RESOURCE_NAME);
final Collection<SavingsProductTempData> pendingEdits = this.depositProductReadPlatformService
.retrieveAllPendingEdits(DepositAccountType.RECURRING_DEPOSIT);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{productId}/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending edit for a Recurring Deposit Product")
public String retrievePending(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_PRODUCT_RESOURCE_NAME);
final SavingsProductTempData pendingEdit = this.depositProductReadPlatformService
.retrievePendingEdit(DepositAccountType.RECURRING_DEPOSIT, productId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{productId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Recurring Deposit Product edit")
public String approvePending(@PathParam("productId") @Parameter(description = "productId") final Long productId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVE_RECURRINGDEPOSITPRODUCT")) {
throw new NoAuthorizationException("User has no authority to approve recurring deposit products");
}
final CommandProcessingResult result = this.recurringDepositProductWritePlatformService.approveEdit(productId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{productId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Recurring Deposit Product edit")
public String rejectPending(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECT_RECURRINGDEPOSITPRODUCT")) {
throw new NoAuthorizationException("User has no authority to reject recurring deposit products");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.recurringDepositProductWritePlatformService.rejectEdit(productId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@@ -59,8 +59,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService;
@@ -74,9 +77,11 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYea
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformService;
import org.apache.fineract.portfolio.tax.data.TaxGroupData;
import org.apache.fineract.portfolio.tax.service.TaxReadPlatformService;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -105,6 +110,9 @@ public class SavingsProductsApiResource {
private final TaxReadPlatformService taxReadPlatformService;
private final ConfigurationDomainService configurationDomainService;
private final JdbcTemplate jdbcTemplate;
private final SavingsProductWritePlatformService savingsProductWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<SavingsProductTempData> tempDataApiJsonSerializer;
@POST
@@ -135,15 +143,92 @@ public class SavingsProductsApiResource {
public String update(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSavingProduct(productId).withJson(apiRequestBodyAsJson)
.build();
// Bypasses the native command-source/maker-checker pipeline by design: edits are held in
// m_savings_product_temp (a pending row there is itself the lock) and only applied to m_savings_product on
// approval - see /savingsproducts/{productId}/approve and /reject below.
this.context.authenticatedUser().validateHasUpdatePermission(SavingsApiConstants.SAVINGS_PRODUCT_RESOURCE_NAME);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
final JsonCommand command = JsonCommand.fromJsonElement(productId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.savingsProductWritePlatformService.submitEditForApproval(productId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Savings Products pending edit approval", description = "Lists all savings product edits currently awaiting checker approval")
public String retrieveAllPending(@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_PRODUCT_RESOURCE_NAME);
final Collection<SavingsProductTempData> pendingEdits = this.savingProductReadPlatformService.retrieveAllPendingEdits();
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{productId}/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending edit for a Savings Product", description = "Retrieves the draft awaiting checker approval for this product, if any")
public String retrievePending(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_PRODUCT_RESOURCE_NAME);
final SavingsProductTempData pendingEdit = this.savingProductReadPlatformService.retrievePendingEdit(productId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.tempDataApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{productId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Savings Product edit", description = "Replays the pending edit and applies it to the Savings Product. The maker of the edit cannot also approve it.")
public String approvePending(@PathParam("productId") @Parameter(description = "productId") final Long productId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVE_SAVINGSPRODUCT")) {
throw new NoAuthorizationException("User has no authority to approve savings products");
}
final CommandProcessingResult result = this.savingsProductWritePlatformService.approveEdit(productId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{productId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Savings Product edit", description = "Discards the pending edit without applying it. The maker of the edit cannot also reject it.")
public String rejectPending(@PathParam("productId") @Parameter(description = "productId") final Long productId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECT_SAVINGSPRODUCT")) {
throw new NoAuthorizationException("User has no authority to reject savings products");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.savingsProductWritePlatformService.rejectEdit(productId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@@ -18,10 +18,13 @@
*/
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingEnumerations;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
@@ -34,7 +37,9 @@ import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.data.DepositProductData;
import org.apache.fineract.portfolio.savings.data.FixedDepositProductData;
import org.apache.fineract.portfolio.savings.data.RecurringDepositProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNoPendingEditException;
import org.apache.fineract.portfolio.tax.data.TaxGroupData;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -124,6 +129,60 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
return null;
}
private static final Gson GSON = new Gson();
private static String productPendingType(final DepositAccountType depositAccountType) {
return depositAccountType.isFixedDeposit() ? "FD" : "RD";
}
private static final RowMapper<SavingsProductTempData> PENDING_EDIT_MAPPER = (rs, rowNum) -> {
final SavingsProductTempData data = new SavingsProductTempData();
data.setId(rs.getLong("tempId"));
data.setSavingsProductId(rs.getLong("savingsProductId"));
data.setSavingsProductName(rs.getString("savingsProductName"));
data.setMakerId(rs.getLong("makerId"));
data.setMakerUsername(rs.getString("makerUsername"));
data.setCheckerId(JdbcSupport.getLong(rs, "checkerId"));
data.setCheckerUsername(rs.getString("checkerUsername"));
data.setCreatedOn(rs.getTimestamp("createdOn") == null ? null : rs.getTimestamp("createdOn").toLocalDateTime());
data.setCheckedOn(rs.getTimestamp("checkedOn") == null ? null : rs.getTimestamp("checkedOn").toLocalDateTime());
data.setStatus(rs.getString("status"));
data.setRejectReason(rs.getString("rejectReason"));
final String proposedPayloadJson = rs.getString("proposedPayloadJson");
if (proposedPayloadJson != null) {
data.setProposedChanges(GSON.fromJson(proposedPayloadJson, new TypeToken<Map<String, Object>>() {}.getType()));
}
return data;
};
private static String pendingEditSchema(final String productType) {
return "spt.id as tempId, spt.product_id as savingsProductId, " + "sp.name as savingsProductName, spt.maker_id as makerId, "
+ "maker.username as makerUsername, spt.checker_id as checkerId, checker.username as checkerUsername, "
+ "spt.created_on as createdOn, spt.checked_on as checkedOn, spt.status as status, "
+ "spt.reject_reason as rejectReason, spt.proposed_payload_json as proposedPayloadJson "
+ "from m_savings_product_temp spt " + "join m_savings_product sp on sp.id = spt.product_id "
+ "join m_appuser maker on maker.id = spt.maker_id " + "left join m_appuser checker on checker.id = spt.checker_id "
+ "where spt.product_type = '" + productType + "' ";
}
@Override
public Collection<SavingsProductTempData> retrieveAllPendingEdits(final DepositAccountType depositAccountType) {
final String sql = "select " + pendingEditSchema(productPendingType(depositAccountType))
+ "and spt.status = 'PENDING' order by spt.created_on desc";
return this.jdbcTemplate.query(sql, PENDING_EDIT_MAPPER);
}
@Override
public SavingsProductTempData retrievePendingEdit(final DepositAccountType depositAccountType, final Long productId) {
final String sql = "select " + pendingEditSchema(productPendingType(depositAccountType))
+ "and spt.status = 'PENDING' and spt.product_id = ?";
try {
return this.jdbcTemplate.queryForObject(sql, PENDING_EDIT_MAPPER, new Object[] { productId }); // NOSONAR
} catch (final EmptyResultDataAccessException e) {
throw new SavingsProductNoPendingEditException(productId);
}
}
private abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
private final String schemaSql;
@@ -255,7 +314,9 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("sp.no_penalty_nominee_heir_closure as noPenaltyNomineeHeirClosure, ");
sqlBuilder.append("sp.deceased_settlement as deceasedSettlement, ");
sqlBuilder.append("sp.allow_debit_txn_after_due_date as allowDebitTxnAfterDueDate, ");
sqlBuilder.append("sp.withdrawal_fee_for_transfer as withdrawalFeeForTransfers ");
sqlBuilder.append("sp.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, ");
sqlBuilder.append("exists(select 1 from m_savings_product_temp spt where spt.product_id = sp.id "
+ "and spt.product_type = 'FD' and spt.status = 'PENDING') as isPendingApproval ");
sqlBuilder.append("from m_savings_product sp ");
sqlBuilder.append("left join m_deposit_product_term_and_preclosure dptp on sp.id=dptp.savings_product_id ");
sqlBuilder.append("join m_currency curr on curr.code = sp.currency_code ");
@@ -345,6 +406,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
result.setDeceasedSettlement(rs.getBoolean("deceasedSettlement"));
result.setAllowDebitTxnAfterDueDate(rs.getBoolean("allowDebitTxnAfterDueDate"));
result.setWithdrawalFeeForTransfers(rs.getBoolean("withdrawalFeeForTransfers"));
result.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return result;
}
}
@@ -392,7 +454,9 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("sp.no_penalty_nominee_heir_closure as noPenaltyNomineeHeirClosure, ");
sqlBuilder.append("sp.deceased_settlement as deceasedSettlement, ");
sqlBuilder.append("sp.allow_debit_txn_after_due_date as allowDebitTxnAfterDueDate, ");
sqlBuilder.append("sp.withdrawal_fee_for_transfer as withdrawalFeeForTransfers ");
sqlBuilder.append("sp.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, ");
sqlBuilder.append("exists(select 1 from m_savings_product_temp spt where spt.product_id = sp.id "
+ "and spt.product_type = 'RD' and spt.status = 'PENDING') as isPendingApproval ");
sqlBuilder.append("from m_savings_product sp ");
sqlBuilder.append("left join m_deposit_product_term_and_preclosure dptp on sp.id=dptp.savings_product_id ");
sqlBuilder.append("left join m_deposit_product_recurring_detail dprd on sp.id=dprd.savings_product_id ");
@@ -513,6 +577,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
result.setDeceasedSettlement(deceasedSettlement);
result.setAllowDebitTxnAfterDueDate(allowDebitTxnAfterDueDate);
result.setWithdrawalFeeForTransfers(withdrawalFeeForTransfers);
result.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return result;
}
}
@@ -32,6 +32,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
@@ -39,6 +42,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
@@ -48,8 +53,14 @@ import org.apache.fineract.portfolio.savings.data.DepositProductDataValidator;
import org.apache.fineract.portfolio.savings.domain.DepositProductAssembler;
import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct;
import org.apache.fineract.portfolio.savings.domain.FixedDepositProductRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductEditPendingApprovalException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNoPendingEditException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.tax.domain.TaxGroup;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataAccessException;
import org.springframework.transaction.annotation.Transactional;
@@ -63,6 +74,9 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
private final DepositProductAssembler depositProductAssembler;
private final ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService;
private final InterestRateChartAssembler chartAssembler;
private final ProductPendingEditRepository productPendingEditRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
@Transactional
@Override
@@ -150,6 +164,124 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
}
}
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long productId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final FixedDepositProduct product = this.fixedDepositProductRepository.findById(productId)
.orElseThrow(() -> new FixedDepositProductNotFoundException(productId));
if (this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.isPresent()) {
throw new SavingsProductEditPendingApprovalException(productId);
}
this.fromApiJsonDataValidator.validateForFixedDepositUpdate(command.json());
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_FD, productId, maker, command.json());
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("FIXEDDEPOSITPRODUCT") //
.resourceGetUrl("/fixeddepositproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
.build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long productId) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(productId,
this.fromApiJsonHelper.parse(pendingEdit.getProposedPayloadJson()), this.fromApiJsonHelper);
final CommandProcessingResult result = update(productId, replayedCommand);
pendingEdit.markApproved(checker);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsChecked(checker);
auditEntry.updateForAudit(result);
this.commandSourceRepository.saveAndFlush(auditEntry);
return result;
}
private CommandSource findOrCreateAuditEntry(final ProductPendingEdit pendingEdit, final Long productId) {
if (pendingEdit.getCommandSourceId() != null) {
final java.util.Optional<CommandSource> existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
if (existing.isPresent()) {
return existing.get();
}
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("FIXEDDEPOSITPRODUCT") //
.resourceGetUrl("/fixeddepositproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(pendingEdit.getProposedPayloadJson()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return auditEntry;
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long productId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
pendingEdit.markRejected(checker, rejectReason);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsRejected(checker);
this.commandSourceRepository.saveAndFlush(auditEntry);
return new CommandProcessingResultBuilder() //
.withEntityId(productId) //
.build();
}
@Transactional
@Override
public CommandProcessingResult delete(final Long productId) {
@@ -32,6 +32,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
@@ -39,6 +42,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
@@ -46,10 +51,16 @@ import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.data.DepositProductDataValidator;
import org.apache.fineract.portfolio.savings.domain.DepositProductAssembler;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProduct;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository;
import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductEditPendingApprovalException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNoPendingEditException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.tax.domain.TaxGroup;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataAccessException;
import org.springframework.transaction.annotation.Transactional;
@@ -63,6 +74,9 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
private final DepositProductAssembler depositProductAssembler;
private final ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService;
private final InterestRateChartAssembler chartAssembler;
private final ProductPendingEditRepository productPendingEditRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
@Transactional
@Override
@@ -150,6 +164,124 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
}
}
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long productId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final RecurringDepositProduct product = this.recurringDepositProductRepository.findById(productId)
.orElseThrow(() -> new RecurringDepositProductNotFoundException(productId));
if (this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.isPresent()) {
throw new SavingsProductEditPendingApprovalException(productId);
}
this.fromApiJsonDataValidator.validateForRecurringDepositUpdate(command.json());
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_RD, productId, maker, command.json());
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("RECURRINGDEPOSITPRODUCT") //
.resourceGetUrl("/recurringdepositproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
.build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long productId) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(productId,
this.fromApiJsonHelper.parse(pendingEdit.getProposedPayloadJson()), this.fromApiJsonHelper);
final CommandProcessingResult result = update(productId, replayedCommand);
pendingEdit.markApproved(checker);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsChecked(checker);
auditEntry.updateForAudit(result);
this.commandSourceRepository.saveAndFlush(auditEntry);
return result;
}
private CommandSource findOrCreateAuditEntry(final ProductPendingEdit pendingEdit, final Long productId) {
if (pendingEdit.getCommandSourceId() != null) {
final java.util.Optional<CommandSource> existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
if (existing.isPresent()) {
return existing.get();
}
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("RECURRINGDEPOSITPRODUCT") //
.resourceGetUrl("/recurringdepositproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(pendingEdit.getProposedPayloadJson()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return auditEntry;
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long productId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
pendingEdit.markRejected(checker, rejectReason);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsRejected(checker);
this.commandSourceRepository.saveAndFlush(auditEntry);
return new CommandProcessingResultBuilder() //
.withEntityId(productId) //
.build();
}
@Transactional
@Override
public CommandProcessingResult delete(final Long productId) {
@@ -38,12 +38,14 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimit;
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKyc;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfigRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNoPendingEditException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.tax.data.TaxGroupData;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -170,6 +172,57 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead
}
}
private static final String PENDING_EDIT_SCHEMA = "spt.id as tempId, spt.product_id as savingsProductId, "
+ "sp.name as savingsProductName, spt.maker_id as makerId, maker.username as makerUsername, "
+ "spt.checker_id as checkerId, checker.username as checkerUsername, spt.created_on as createdOn, "
+ "spt.checked_on as checkedOn, spt.status as status, spt.reject_reason as rejectReason, "
+ "spt.proposed_payload_json as proposedPayloadJson "
+ "from m_savings_product_temp spt "
+ "join m_savings_product sp on sp.id = spt.product_id "
+ "join m_appuser maker on maker.id = spt.maker_id "
+ "left join m_appuser checker on checker.id = spt.checker_id "
+ "where spt.product_type = 'SAVINGS' ";
private static final RowMapper<SavingsProductTempData> PENDING_EDIT_MAPPER = (rs, rowNum) -> {
final SavingsProductTempData data = new SavingsProductTempData();
data.setId(rs.getLong("tempId"));
data.setSavingsProductId(rs.getLong("savingsProductId"));
data.setSavingsProductName(rs.getString("savingsProductName"));
data.setMakerId(rs.getLong("makerId"));
data.setMakerUsername(rs.getString("makerUsername"));
data.setCheckerId(JdbcSupport.getLong(rs, "checkerId"));
data.setCheckerUsername(rs.getString("checkerUsername"));
data.setCreatedOn(rs.getTimestamp("createdOn") == null ? null : rs.getTimestamp("createdOn").toLocalDateTime());
data.setCheckedOn(rs.getTimestamp("checkedOn") == null ? null : rs.getTimestamp("checkedOn").toLocalDateTime());
data.setStatus(rs.getString("status"));
data.setRejectReason(rs.getString("rejectReason"));
final String proposedPayloadJson = rs.getString("proposedPayloadJson");
if (proposedPayloadJson != null) {
final Map<String, Object> proposedChanges = GSON.fromJson(proposedPayloadJson,
new TypeToken<Map<String, Object>>() {}.getType());
data.setProposedChanges(proposedChanges);
}
return data;
};
@Override
public Collection<SavingsProductTempData> retrieveAllPendingEdits() {
this.context.authenticatedUser();
final String sql = "select " + PENDING_EDIT_SCHEMA + "and spt.status = 'PENDING' order by spt.created_on desc";
return this.jdbcTemplate.query(sql, PENDING_EDIT_MAPPER);
}
@Override
public SavingsProductTempData retrievePendingEdit(final Long productId) {
this.context.authenticatedUser();
final String sql = "select " + PENDING_EDIT_SCHEMA + "and spt.status = 'PENDING' and spt.product_id = ?";
try {
return this.jdbcTemplate.queryForObject(sql, PENDING_EDIT_MAPPER, new Object[] { productId }); // NOSONAR
} catch (final EmptyResultDataAccessException e) {
throw new SavingsProductNoPendingEditException(productId);
}
}
private static final class SavingProductMapper implements RowMapper<SavingsProductData> {
private final String schemaSql;
@@ -217,7 +270,9 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead
sqlBuilder.append("sp.auto_post as autoPost, sp.frequency_fix_day as frequencyFixDay, sp.last_interest_date as lastInterestDate, ");
sqlBuilder.append("sp.enable_lockin_period as enableLockinPeriod, ");
sqlBuilder.append("sp.balance_frequency as balanceFrequency, ");
sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing ");
sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("exists(select 1 from m_savings_product_temp spt where spt.product_id = sp.id "
+ "and spt.product_type = 'SAVINGS' and spt.status = 'PENDING') as isPendingApproval ");
sqlBuilder.append("from m_savings_product sp ");
sqlBuilder.append("join m_currency curr on curr.code = sp.currency_code ");
@@ -340,6 +395,7 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead
productData.setEnableLockinPeriod(rs.getBoolean("enableLockinPeriod"));
productData.setBalanceFrequency(rs.getString("balanceFrequency"));
productData.setAccountClosedZeroBalanceInDayClosing(rs.getBoolean("accountClosedZeroBalanceInDayClosing"));
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return productData;
}
@@ -46,6 +46,11 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.data.SavingsProductDataValidator;
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKyc;
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository;
@@ -56,8 +61,24 @@ import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitR
import org.apache.fineract.portfolio.savings.domain.SavingsProduct;
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductAssembler;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditCharge;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimit;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimitRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMapping;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedField;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMapper;
import com.google.gson.JsonObject;
import org.apache.fineract.portfolio.savings.exception.SavingsProductEditPendingApprovalException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNoPendingEditException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.tax.domain.TaxGroup;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.transaction.annotation.Transactional;
@@ -75,6 +96,13 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
private final SavingsProductFacilityConfigRepository facilityConfigRepository;
private final SavingsProductFacilityLimitRepository facilityLimitRepository;
private final SavingsProductDepositLimitKycRepository depositLimitKycRepository;
private final ProductPendingEditRepository productPendingEditRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
private final ProductPendingEditChargeRepository pendingEditChargeRepository;
private final ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository;
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository;
/*
* Guaranteed to throw an exception no matter what the data integrity issue is.
@@ -279,6 +307,214 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
}
}
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long productId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final SavingsProduct product = this.savingProductRepository.findById(productId)
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
if (this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.isPresent()) {
throw new SavingsProductEditPendingApprovalException(productId);
}
// Reuses the same validator the direct-update path uses, so bad data can't sit "pending" undetected.
this.fromApiJsonDataValidator.validateForUpdate(command.json(), product);
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_SAVINGS, productId, maker, command.json());
// Typed-column storage: extract every recognised field into the pending edit's own columns
// (plus satellite rows for the 1:many pieces) instead of relying solely on the JSON blob
// above. proposedPayloadJson is still populated for now (safe deprecation window) but is no
// longer what approveEdit replays from - see approveEdit().
final java.util.Set<String> submitted = ProductPendingEditFieldMapper.populateCoreFields(command, pendingEdit.getCoreFields());
final boolean facilityConfigSubmitted = ProductPendingEditFieldMapper.populateFacilityConfig(command,
pendingEdit.getFacilityConfigFields());
final boolean depositLimitKycSubmitted = ProductPendingEditFieldMapper.populateDepositLimitKyc(command,
pendingEdit.getDepositLimitKycFields());
final boolean glMappingSubmitted = ProductPendingEditFieldMapper.populateGlAccountMapping(command,
pendingEdit.getGlAccountMappingFields());
if (facilityConfigSubmitted) {
submitted.add("accountFacilityConfig");
}
if (depositLimitKycSubmitted) {
submitted.add("depositLimitKyc");
}
if (glMappingSubmitted) {
submitted.add("glAccountMapping");
}
// Audit-only entry so this submission is visible in System -> Audit Trails even though it bypasses the
// native command-processing/maker-checker pipeline (the real gate is m_savings_product_temp itself).
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("SAVINGSPRODUCT") //
.resourceGetUrl("/savingsproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
for (final String fieldName : submitted) {
this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, fieldName));
}
final List<Long> chargeIds = ProductPendingEditFieldMapper.extractChargeIds(command);
if (chargeIds != null) {
this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "charges"));
for (final Long chargeId : chargeIds) {
this.pendingEditChargeRepository.save(new ProductPendingEditCharge(pendingEdit, chargeId));
}
}
final List<ProductPendingEditFacilityLimit> facilityLimits = ProductPendingEditFieldMapper.extractFacilityLimits(command,
pendingEdit);
if (facilityLimits != null) {
this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "accountFacilityLimits"));
this.pendingEditFacilityLimitRepository.saveAll(facilityLimits);
}
final List<ProductPendingEditGlMapping> glMappings = ProductPendingEditFieldMapper.extractGlArrayMappings(command, pendingEdit);
if (glMappings != null) {
this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "glArrayMappings"));
this.pendingEditGlMappingRepository.saveAll(glMappings);
}
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
.build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long productId) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
// Replay through the real update path so charges, GL/accounting mappings and other related
// tables are applied exactly as a direct edit would apply them - not hand-copied field by
// field. The replay JSON is rebuilt from this row's typed columns/satellite tables (the
// manifest records exactly which top-level parameters the maker actually submitted); pending
// edits created before this cutover have no manifest rows, so they fall back to replaying the
// legacy JSON blob directly.
final Set<String> submittedFields = new java.util.HashSet<>(
this.pendingEditSubmittedFieldRepository.findFieldNamesByPendingEditId(pendingEdit.getId()));
final JsonCommand replayedCommand;
if (submittedFields.isEmpty()) {
replayedCommand = JsonCommand.fromJsonElement(productId, this.fromApiJsonHelper.parse(pendingEdit.getProposedPayloadJson()),
this.fromApiJsonHelper);
} else {
final JsonObject root = new JsonObject();
root.addProperty("locale", "en");
root.addProperty("dateFormat", "yyyy-MM-dd");
ProductPendingEditFieldMapper.writeCoreFieldsToJson(root, pendingEdit.getCoreFields(), submittedFields);
ProductPendingEditFieldMapper.writeFacilityConfigToJson(root, pendingEdit.getFacilityConfigFields(),
submittedFields.contains("accountFacilityConfig"));
ProductPendingEditFieldMapper.writeDepositLimitKycToJson(root, pendingEdit.getDepositLimitKycFields(),
submittedFields.contains("depositLimitKyc"));
if (submittedFields.contains("glAccountMapping")) {
ProductPendingEditFieldMapper.writeGlAccountMappingToJson(root, pendingEdit.getGlAccountMappingFields());
}
if (submittedFields.contains("charges")) {
ProductPendingEditFieldMapper.writeChargesToJson(root,
this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId()));
}
if (submittedFields.contains("accountFacilityLimits")) {
ProductPendingEditFieldMapper.writeFacilityLimitsToJson(root,
this.pendingEditFacilityLimitRepository.findByPendingEditId(pendingEdit.getId()));
}
if (submittedFields.contains("glArrayMappings")) {
ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root,
this.pendingEditGlMappingRepository.findByPendingEditId(pendingEdit.getId()));
}
replayedCommand = JsonCommand.fromJsonElement(productId, root, this.fromApiJsonHelper);
}
final CommandProcessingResult result = update(productId, replayedCommand);
pendingEdit.markApproved(checker);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsChecked(checker);
auditEntry.updateForAudit(result);
this.commandSourceRepository.saveAndFlush(auditEntry);
return result;
}
/**
* Every edit submitted through submitEditForApproval() links a CommandSource audit row. This fallback only
* matters for pending edits that were created before that linking existed - it backfills one now so approve
* and reject always leave an entry in m_portfolio_command_source, with no silent no-op case.
*/
private CommandSource findOrCreateAuditEntry(final ProductPendingEdit pendingEdit, final Long productId) {
if (pendingEdit.getCommandSourceId() != null) {
final java.util.Optional<CommandSource> existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
if (existing.isPresent()) {
return existing.get();
}
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("SAVINGSPRODUCT") //
.resourceGetUrl("/savingsproducts/" + productId) //
.resourceId(productId) //
.commandAsJson(pendingEdit.getProposedPayloadJson()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return auditEntry;
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long productId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
pendingEdit.markRejected(checker, rejectReason);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsRejected(checker);
this.commandSourceRepository.saveAndFlush(auditEntry);
return new CommandProcessingResultBuilder() //
.withEntityId(productId) //
.build();
}
@Transactional
@Override
public CommandProcessingResult delete(final Long productId) {
@@ -96,6 +96,8 @@ import org.apache.fineract.portfolio.savings.domain.SavingsProductAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfigRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimit;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
@@ -324,9 +326,12 @@ public class SavingsConfiguration {
public FixedDepositProductWritePlatformService fixedDepositProductWritePlatformService(PlatformSecurityContext context,
FixedDepositProductRepository fixedDepositProductRepository, DepositProductDataValidator fromApiJsonDataValidator,
DepositProductAssembler depositProductAssembler,
ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService, InterestRateChartAssembler chartAssembler) {
ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService, InterestRateChartAssembler chartAssembler,
ProductPendingEditRepository productPendingEditRepository, FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
return new FixedDepositProductWritePlatformServiceJpaRepositoryImpl(context, fixedDepositProductRepository,
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler);
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler,
productPendingEditRepository, fromApiJsonHelper, commandSourceRepository);
}
@Bean
@@ -348,9 +353,12 @@ public class SavingsConfiguration {
public RecurringDepositProductWritePlatformService recurringDepositProductWritePlatformService(PlatformSecurityContext context,
RecurringDepositProductRepository recurringDepositProductRepository, DepositProductDataValidator fromApiJsonDataValidator,
DepositProductAssembler depositProductAssembler,
ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService, InterestRateChartAssembler chartAssembler) {
ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService, InterestRateChartAssembler chartAssembler,
ProductPendingEditRepository productPendingEditRepository, FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
return new RecurringDepositProductWritePlatformServiceJpaRepositoryImpl(context, recurringDepositProductRepository,
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler);
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler,
productPendingEditRepository, fromApiJsonHelper, commandSourceRepository);
}
@Bean
@@ -489,10 +497,19 @@ public class SavingsConfiguration {
FineractEntityAccessUtil fineractEntityAccessUtil,
SavingsProductFacilityConfigRepository facilityConfigRepository,
SavingsProductFacilityLimitRepository facilityLimitRepository,
SavingsProductDepositLimitKycRepository depositLimitKycRepository) {
SavingsProductDepositLimitKycRepository depositLimitKycRepository,
ProductPendingEditRepository productPendingEditRepository,
FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository,
org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository pendingEditChargeRepository,
org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository) {
return new SavingsProductWritePlatformServiceJpaRepositoryImpl(context, savingProductRepository, fromApiJsonDataValidator,
savingsProductAssembler, accountMappingWritePlatformService, fineractEntityAccessUtil, facilityConfigRepository,
facilityLimitRepository, depositLimitKycRepository);
facilityLimitRepository, depositLimitKycRepository, productPendingEditRepository, fromApiJsonHelper,
commandSourceRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, pendingEditGlMappingRepository,
pendingEditSubmittedFieldRepository);
}
@Bean
@@ -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.shareproducts.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ShareProductEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public ShareProductEditPendingApprovalException(final Long productId) {
super("error.msg.shareproduct.edit.pending.approval",
"Share product with identifier " + productId + " already has an edit pending approval", productId);
}
}
@@ -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.shareproducts.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ShareProductNoPendingEditException extends AbstractPlatformDomainRuleException {
public ShareProductNoPendingEditException(final Long productId) {
super("error.msg.shareproduct.no.pending.edit", "Share product with identifier " + productId + " has no edit pending approval",
productId);
}
}
@@ -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.shareproducts.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ShareProductSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public ShareProductSelfApprovalNotAllowedException(final Long productId) {
super("error.msg.shareproduct.self.approval.not.allowed",
"The maker of the pending edit for share product " + productId + " cannot also approve or reject it", productId);
}
}
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.shareproducts.service;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.ResultSet;
@@ -46,9 +48,11 @@ import org.apache.fineract.portfolio.charge.service.ChargeReadPlatformService;
import org.apache.fineract.portfolio.products.data.ProductData;
import org.apache.fineract.portfolio.products.exception.ProductNotFoundException;
import org.apache.fineract.portfolio.products.service.ShareProductReadPlatformService;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
import org.apache.fineract.portfolio.shareaccounts.service.SharesEnumerations;
import org.apache.fineract.portfolio.shareproducts.data.ShareProductData;
import org.apache.fineract.portfolio.shareproducts.data.ShareProductMarketPriceData;
import org.apache.fineract.portfolio.shareproducts.exception.ShareProductNoPendingEditException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@@ -152,6 +156,55 @@ public class ShareProductReadPlatformServiceImpl implements ShareProductReadPlat
return null;
}
private static final Gson GSON = new Gson();
private static final String PENDING_EDIT_SCHEMA = "spt.id as tempId, spt.product_id as savingsProductId, "
+ "sp.name as savingsProductName, spt.maker_id as makerId, maker.username as makerUsername, "
+ "spt.checker_id as checkerId, checker.username as checkerUsername, spt.created_on as createdOn, "
+ "spt.checked_on as checkedOn, spt.status as status, spt.reject_reason as rejectReason, "
+ "spt.proposed_payload_json as proposedPayloadJson "
+ "from m_savings_product_temp spt "
+ "join m_share_product sp on sp.id = spt.product_id "
+ "join m_appuser maker on maker.id = spt.maker_id "
+ "left join m_appuser checker on checker.id = spt.checker_id "
+ "where spt.product_type = 'SHARE' ";
private static final RowMapper<SavingsProductTempData> PENDING_EDIT_MAPPER = (rs, rowNum) -> {
final SavingsProductTempData data = new SavingsProductTempData();
data.setId(rs.getLong("tempId"));
data.setSavingsProductId(rs.getLong("savingsProductId"));
data.setSavingsProductName(rs.getString("savingsProductName"));
data.setMakerId(rs.getLong("makerId"));
data.setMakerUsername(rs.getString("makerUsername"));
data.setCheckerId(JdbcSupport.getLong(rs, "checkerId"));
data.setCheckerUsername(rs.getString("checkerUsername"));
data.setCreatedOn(rs.getTimestamp("createdOn") == null ? null : rs.getTimestamp("createdOn").toLocalDateTime());
data.setCheckedOn(rs.getTimestamp("checkedOn") == null ? null : rs.getTimestamp("checkedOn").toLocalDateTime());
data.setStatus(rs.getString("status"));
data.setRejectReason(rs.getString("rejectReason"));
final String proposedPayloadJson = rs.getString("proposedPayloadJson");
if (proposedPayloadJson != null) {
data.setProposedChanges(GSON.fromJson(proposedPayloadJson, new TypeToken<Map<String, Object>>() {}.getType()));
}
return data;
};
@Override
public Collection<SavingsProductTempData> retrieveAllPendingEdits() {
final String sql = "select " + PENDING_EDIT_SCHEMA + "and spt.status = 'PENDING' order by spt.created_on desc";
return this.jdbcTemplate.query(sql, PENDING_EDIT_MAPPER);
}
@Override
public SavingsProductTempData retrievePendingEdit(final Long productId) {
final String sql = "select " + PENDING_EDIT_SCHEMA + "and spt.status = 'PENDING' and spt.product_id = ?";
try {
return this.jdbcTemplate.queryForObject(sql, PENDING_EDIT_MAPPER, new Object[] { productId }); // NOSONAR
} catch (final EmptyResultDataAccessException e) {
throw new ShareProductNoPendingEditException(productId);
}
}
private static final class AllShareProductRowMapper implements RowMapper<ProductData> {
@SuppressWarnings("unused")
@@ -32,4 +32,21 @@ public interface ShareProductWritePlatformService {
CommandProcessingResult approveShareProductDividend(Long PayOutDetailId);
CommandProcessingResult deleteShareProductDividend(Long PayOutDetailId);
/**
* Submits an edit for approval instead of applying it directly. Fails if a pending edit already exists for this
* product (that pending row is itself the lock).
*/
CommandProcessingResult submitEditForApproval(Long productId, JsonCommand command);
/**
* Replays the stored pending edit through the normal update path and marks it approved. Fails if the approving
* user is the same user who submitted the edit.
*/
CommandProcessingResult approveEdit(Long productId);
/**
* Discards the pending edit. Fails if the rejecting user is the same user who submitted the edit.
*/
CommandProcessingResult rejectEdit(Long productId, String rejectReason);
}
@@ -27,23 +27,35 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
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.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.event.business.domain.share.ShareProductDividentsCreateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.shareproducts.constants.ShareProductApiConstants;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividendPayOutDetails;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividentPayOutDetailsRepositoryWrapper;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
import org.apache.fineract.portfolio.shareproducts.exception.DividentProcessingException;
import org.apache.fineract.portfolio.shareproducts.exception.ShareProductEditPendingApprovalException;
import org.apache.fineract.portfolio.shareproducts.exception.ShareProductNoPendingEditException;
import org.apache.fineract.portfolio.shareproducts.exception.ShareProductSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.shareproducts.serialization.ShareProductDataSerializer;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
@@ -56,6 +68,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
private final ShareProductDividendAssembler shareProductDividendAssembler;
private final ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService;
private final BusinessEventNotifierService businessEventNotifierService;
private final PlatformSecurityContext context;
private final ProductPendingEditRepository productPendingEditRepository;
private final CommandSourceRepository commandSourceRepository;
@Override
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
@@ -180,6 +195,121 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
}
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long productId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
if (this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.isPresent()) {
throw new ShareProductEditPendingApprovalException(productId);
}
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_SHARE, productId, maker, command.json());
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("SHAREPRODUCT") //
.resourceGetUrl("/products/share/" + productId) //
.resourceId(productId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
.build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long productId) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(productId,
this.fromApiJsonHelper.parse(pendingEdit.getProposedPayloadJson()), this.fromApiJsonHelper);
final CommandProcessingResult result = updateProduct(productId, replayedCommand);
pendingEdit.markApproved(checker);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsChecked(checker);
auditEntry.updateForAudit(result);
this.commandSourceRepository.saveAndFlush(auditEntry);
return result;
}
private CommandSource findOrCreateAuditEntry(final ProductPendingEdit pendingEdit, final Long productId) {
if (pendingEdit.getCommandSourceId() != null) {
final java.util.Optional<CommandSource> existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
if (existing.isPresent()) {
return existing.get();
}
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("UPDATE") //
.entityName("SHAREPRODUCT") //
.resourceGetUrl("/products/share/" + productId) //
.resourceId(productId) //
.commandAsJson(pendingEdit.getProposedPayloadJson()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(java.util.UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.productPendingEditRepository.saveAndFlush(pendingEdit);
return auditEntry;
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long productId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final ProductPendingEdit pendingEdit = this.productPendingEditRepository
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
pendingEdit.markRejected(checker, rejectReason);
this.productPendingEditRepository.saveAndFlush(pendingEdit);
final CommandSource auditEntry = findOrCreateAuditEntry(pendingEdit, productId);
auditEntry.markAsRejected(checker);
this.commandSourceRepository.saveAndFlush(auditEntry);
return new CommandProcessingResultBuilder() //
.withEntityId(productId) //
.build();
}
private void handleDataIntegrityIssues(final Exception e) {
log.error("Unknown data integrity issue with resource", e);
throw ErrorHandler.getMappable(e, "error.msg.shareproduct.unknown.data.integrity.issue",
@@ -24,11 +24,14 @@ import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToG
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService;
import org.apache.fineract.portfolio.charge.service.ChargeReadPlatformService;
import org.apache.fineract.portfolio.products.service.ShareProductReadPlatformService;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatformService;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividentPayOutDetailsRepositoryWrapper;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
@@ -99,10 +102,13 @@ public class ShareProductsConfiguration {
ShareProductDividentPayOutDetailsRepositoryWrapper shareProductDividentPayOutDetailsRepository,
ShareProductDividendAssembler shareProductDividendAssembler,
ProductToGLAccountMappingWritePlatformService accountMappingWritePlatformService,
BusinessEventNotifierService businessEventNotifierService) {
BusinessEventNotifierService businessEventNotifierService,
PlatformSecurityContext context,
ProductPendingEditRepository productPendingEditRepository,
CommandSourceRepository commandSourceRepository) {
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
businessEventNotifierService);
businessEventNotifierService, context, productPendingEditRepository, commandSourceRepository);
}
}
@@ -495,6 +495,7 @@ public final class FixedDepositProductData extends DepositProductData {
private Boolean allowDebitTxnAfterDueDate;
private Boolean accountClosedZeroBalanceInDayClosing;
private String balanceFrequency;
private Boolean isPendingApproval;
// Template option setters
public void setInterestCalculationTypeOption(Collection<org.apache.fineract.infrastructure.core.data.EnumOptionData> v) { this.interestCalculationTypeOption = v; }
@@ -541,6 +542,7 @@ public final class FixedDepositProductData extends DepositProductData {
public void setAllowDebitTxnAfterDueDate(Boolean v) { this.allowDebitTxnAfterDueDate = v; }
public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; }
public void setBalanceFrequency(String v) { this.balanceFrequency = v; }
public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; }
// CBS field getters
public String getProductCode() { return productCode; }
@@ -98,6 +98,7 @@ public final class RecurringDepositProductData extends DepositProductData {
private Boolean deceasedSettlement;
private Boolean allowDebitTxnAfterDueDate;
private Boolean withdrawalFeeForTransfers;
private Boolean isPendingApproval;
public void setInterestCalculationTypeOption(Collection<EnumOptionData> v) { this.interestCalculationTypeOption = v; }
public void setPeriodFrequencyOptions(Collection<EnumOptionData> v) { this.periodFrequencyOptions = v; }
@@ -140,6 +141,7 @@ public final class RecurringDepositProductData extends DepositProductData {
public void setDeceasedSettlement(Boolean v) { this.deceasedSettlement = v; }
public void setAllowDebitTxnAfterDueDate(Boolean v) { this.allowDebitTxnAfterDueDate = v; }
public void setWithdrawalFeeForTransfers(Boolean v) { this.withdrawalFeeForTransfers = v; }
public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; }
/** Copy all CBS custom fields from source into this instance. */
public void copyCbsFieldsFrom(final RecurringDepositProductData src) {
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Getter;
import lombok.Setter;
@Embeddable
@Getter
@Setter
public class CurrentOnlyEditFields {
@Column(name = "avg_balance_calc_period")
private String avgBalanceCalcPeriod;
@Column(name = "valid_for_period")
private String validForPeriod;
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private Boolean odAccountClosedZeroBalanceInDayClosing;
}
@@ -0,0 +1,54 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Getter;
import lombok.Setter;
/**
* Mirrors the "depositLimitKyc" JSON object - a 1:1 relationship today, so kept flat here. Note:
* the real JSON shape is nested ({@code {general: {kycType, limit}, minor: {...}, smallSaving:
* {...}}}), not flat siblings - the replay step is responsible for re-nesting these columns back
* into that shape before handing off to the unchanged assembler.
*/
@Embeddable
@Getter
@Setter
public class DepositLimitKycEditFields {
@Column(name = "kyc_general_type")
private String generalType;
@Column(name = "kyc_general_limit")
private String generalLimit;
@Column(name = "kyc_minor_type")
private String minorType;
@Column(name = "kyc_minor_limit")
private Long minorLimit;
@Column(name = "kyc_small_saving_type")
private String smallSavingType;
@Column(name = "kyc_small_saving_limit")
private Long smallSavingLimit;
}
@@ -0,0 +1,79 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Getter;
import lombok.Setter;
/** Mirrors the "accountFacilityConfig" JSON object - a 1:1 relationship today, so kept flat here. */
@Embeddable
@Getter
@Setter
public class FacilityConfigEditFields {
@Column(name = "fc_atm_facility")
private Boolean atmFacility;
@Column(name = "fc_atm_withdrawal_facility")
private Boolean atmWithdrawalFacility;
@Column(name = "fc_upi_facility")
private Boolean upiFacility;
@Column(name = "fc_pos_transactions_facility")
private Boolean posTransactionsFacility;
@Column(name = "fc_cheque_book")
private Boolean chequeBook;
@Column(name = "fc_imps_facility")
private Boolean impsFacility;
@Column(name = "fc_sms_alerts")
private Boolean smsAlerts;
@Column(name = "fc_bbps_facility")
private Boolean bbpsFacility;
@Column(name = "fc_insurance_cover")
private Boolean insuranceCover;
@Column(name = "fc_bepg_facility")
private Boolean bepgFacility;
@Column(name = "fc_neft_facility")
private Boolean neftFacility;
@Column(name = "fc_rtgs_facility")
private Boolean rtgsFacility;
@Column(name = "fc_inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "fc_withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "fc_deposit_facility_kyc")
private Boolean depositFacilityKyc;
@Column(name = "fc_virtual_debit_card")
private Boolean virtualDebitCard;
}
@@ -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.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.Setter;
/** Also used by Recurring Deposit, which extends Fixed Deposit. */
@Embeddable
@Getter
@Setter
public class FixedDepositEditFields {
@Column(name = "fd_type")
private String fdType;
@Column(name = "pre_closure_penal_applicable")
private Boolean preClosurePenalApplicable;
@Column(name = "pre_closure_penal_interest")
private BigDecimal preClosurePenalInterest;
@Column(name = "pre_closure_penal_interest_on_type_id")
private Integer preClosurePenalInterestOnTypeId;
@Column(name = "min_deposit_term")
private Integer minDepositTerm;
@Column(name = "max_deposit_term")
private Integer maxDepositTerm;
@Column(name = "min_deposit_term_type_id")
private Integer minDepositTermTypeId;
@Column(name = "max_deposit_term_type_id")
private Integer maxDepositTermTypeId;
@Column(name = "in_multiples_of_deposit_term")
private Integer inMultiplesOfDepositTerm;
@Column(name = "in_multiples_of_deposit_term_type_id")
private Integer inMultiplesOfDepositTermTypeId;
@Column(name = "min_deposit_amount")
private BigDecimal minDepositAmount;
@Column(name = "max_deposit_amount")
private BigDecimal maxDepositAmount;
@Column(name = "deposit_amount")
private BigDecimal depositAmount;
}
@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Getter;
import lombok.Setter;
/**
* Scalar (1:1) GL account mapping ids. No FK constraints to the accounting GL table - the
* existing validator already checks GL account validity at submit time. The 3 array-shaped GL
* mappings (payment-channel/fee/penalty to income account) live in m_product_temp_gl_mapping
* instead, since those are genuinely one-to-many.
*/
@Embeddable
@Getter
@Setter
public class GlAccountMappingEditFields {
@Column(name = "gl_savings_reference_account_id")
private Long savingsReferenceAccountId;
@Column(name = "gl_savings_control_account_id")
private Long savingsControlAccountId;
@Column(name = "gl_income_from_fee_account_id")
private Long incomeFromFeeAccountId;
@Column(name = "gl_income_from_penalty_account_id")
private Long incomeFromPenaltyAccountId;
@Column(name = "gl_interest_on_savings_account_id")
private Long interestOnSavingsAccountId;
@Column(name = "gl_transfers_in_suspense_account_id")
private Long transfersInSuspenseAccountId;
@Column(name = "gl_overdraft_portfolio_control_id")
private Long overdraftPortfolioControlId;
@Column(name = "gl_income_from_interest_id")
private Long incomeFromInterestId;
@Column(name = "gl_write_off_account_id")
private Long writeOffAccountId;
@Column(name = "gl_escheat_liability_id")
private Long escheatLiabilityId;
@Column(name = "gl_penalties_receivable_account_id")
private Long penaltiesReceivableAccountId;
@Column(name = "gl_interest_receivable_account_id")
private Long interestReceivableAccountId;
@Column(name = "gl_fees_receivable_account_id")
private Long feesReceivableAccountId;
@Column(name = "gl_interest_payable_account_id")
private Long interestPayableAccountId;
}
@@ -0,0 +1,209 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Holds a single pending product edit, shared across product types (Savings, Current Account, Share) in one table
* (m_savings_product_temp). Presence of a row here for a given (productType, productId) with
* {@code status = PENDING} is itself the lock - no separate "is locked" column anywhere. The full edit request body
* is kept in {@link #proposedPayloadJson} so approval can replay it through each product's normal update path
* (covering charges, GL/accounting mappings and other related tables, not just scalar columns).
*/
@Entity
@Table(name = "m_savings_product_temp")
@Getter
@Setter
public class ProductPendingEdit extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String TYPE_SAVINGS = "SAVINGS";
public static final String TYPE_CURRENT = "CURRENT";
public static final String TYPE_SHARE = "SHARE";
public static final String TYPE_FD = "FD";
public static final String TYPE_RD = "RD";
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(name = "product_type", nullable = false, length = 20)
private String productType;
@ManyToOne
@JoinColumn(name = "maker_id", nullable = false)
private AppUser maker;
@ManyToOne
@JoinColumn(name = "checker_id")
private AppUser checker;
@Column(name = "created_on", nullable = false)
private LocalDateTime createdOn;
@Column(name = "checked_on")
private LocalDateTime checkedOn;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
@Lob
@Column(name = "proposed_payload_json", nullable = false)
private String proposedPayloadJson;
/**
* "<productType>:<productId>" while PENDING, NULL otherwise - the real DB-level lock. Unique constraints treat
* NULL as distinct in both PostgreSQL and MySQL/MariaDB, so only one PENDING row per (productType, productId)
* can ever exist, while resolved rows are kept indefinitely for audit history without blocking future edits.
*/
@Column(name = "pending_lock_key", length = 40)
private String pendingLockKey;
/** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */
@Column(name = "command_source_id")
private Long commandSourceId;
/**
* Typed replacement for proposedPayloadJson - one embeddable group per concern, all mapped
* onto this same physical table. proposedPayloadJson is still populated (kept for a safe
* deprecation window) but a product type's write-service uses these typed columns, plus the
* satellite tables (ProductPendingEditCharge/FacilityLimit/GlMapping/MarketPrice) and the
* ProductPendingEditSubmittedField manifest, to build and replay an edit once its cutover
* (see SavingsProductWritePlatformServiceJpaRepositoryImpl and siblings) is complete.
*/
@Embedded
private SavingsCoreEditFields coreFields = new SavingsCoreEditFields();
@Embedded
private CurrentOnlyEditFields currentOnlyFields = new CurrentOnlyEditFields();
@Embedded
private ShareOnlyEditFields shareOnlyFields = new ShareOnlyEditFields();
@Embedded
private FixedDepositEditFields fixedDepositFields = new FixedDepositEditFields();
@Embedded
private RecurringDepositEditFields recurringDepositFields = new RecurringDepositEditFields();
@Embedded
private FacilityConfigEditFields facilityConfigFields = new FacilityConfigEditFields();
@Embedded
private DepositLimitKycEditFields depositLimitKycFields = new DepositLimitKycEditFields();
@Embedded
private GlAccountMappingEditFields glAccountMappingFields = new GlAccountMappingEditFields();
protected ProductPendingEdit() {}
public ProductPendingEdit(final String productType, final Long productId, final AppUser maker, final String proposedPayloadJson) {
this.productType = productType;
this.productId = productId;
this.maker = maker;
this.proposedPayloadJson = proposedPayloadJson;
this.createdOn = LocalDateTime.now();
this.status = STATUS_PENDING;
this.pendingLockKey = lockKeyFor(productType, productId);
}
public static String lockKeyFor(final String productType, final Long productId) {
return productType + ":" + productId;
}
public Long getProductId() {
return this.productId;
}
public String getProductType() {
return this.productType;
}
public AppUser getMaker() {
return this.maker;
}
public AppUser getChecker() {
return this.checker;
}
public LocalDateTime getCreatedOn() {
return this.createdOn;
}
public LocalDateTime getCheckedOn() {
return this.checkedOn;
}
public String getStatus() {
return this.status;
}
public String getRejectReason() {
return this.rejectReason;
}
public String getProposedPayloadJson() {
return this.proposedPayloadJson;
}
public Long getCommandSourceId() {
return this.commandSourceId;
}
public void setCommandSourceId(final Long commandSourceId) {
this.commandSourceId = commandSourceId;
}
public boolean isPending() {
return STATUS_PENDING.equals(this.status);
}
public void markApproved(final AppUser checker) {
this.status = STATUS_APPROVED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.pendingLockKey = null;
}
public void markRejected(final AppUser checker, final String reason) {
this.status = STATUS_REJECTED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/** One row per charge id proposed to be attached to the product, mirroring the "charges" array. */
@Entity
@Table(name = "m_product_temp_charge")
@Getter
@Setter
@NoArgsConstructor
public class ProductPendingEditCharge extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "pending_edit_id", nullable = false)
private ProductPendingEdit pendingEdit;
@Column(name = "charge_id", nullable = false)
private Long chargeId;
public ProductPendingEditCharge(final ProductPendingEdit pendingEdit, final Long chargeId) {
this.pendingEdit = pendingEdit;
this.chargeId = chargeId;
}
}
@@ -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.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductPendingEditChargeRepository extends JpaRepository<ProductPendingEditCharge, Long> {
List<ProductPendingEditCharge> findByPendingEditId(Long pendingEditId);
}
@@ -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.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/** One row per entry of the "accountFacilityLimits" array. Values can be "UNLIMITED"/"N/A"/numeric-as-string. */
@Entity
@Table(name = "m_product_temp_facility_limit")
@Getter
@Setter
@NoArgsConstructor
public class ProductPendingEditFacilityLimit extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "pending_edit_id", nullable = false)
private ProductPendingEdit pendingEdit;
@Column(name = "limit_type", nullable = false)
private String limitType;
@Column(name = "general_limit")
private String generalLimit;
@Column(name = "minor_account_limit")
private String minorAccountLimit;
@Column(name = "small_saving_limit")
private String smallSavingLimit;
public ProductPendingEditFacilityLimit(final ProductPendingEdit pendingEdit, final String limitType, final String generalLimit,
final String minorAccountLimit, final String smallSavingLimit) {
this.pendingEdit = pendingEdit;
this.limitType = limitType;
this.generalLimit = generalLimit;
this.minorAccountLimit = minorAccountLimit;
this.smallSavingLimit = smallSavingLimit;
}
}
@@ -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.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductPendingEditFacilityLimitRepository extends JpaRepository<ProductPendingEditFacilityLimit, Long> {
List<ProductPendingEditFacilityLimit> findByPendingEditId(Long pendingEditId);
}
@@ -0,0 +1,696 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
/**
* Shared submit-time extraction / approve-time reconstruction logic for the scalar and 1:1
* "container" fields that Savings, Current, Fixed Deposit and Recurring Deposit products all have
* in common (FD/RD extend the Savings field set). Each populateXxx method reads present JSON
* parameters into the corresponding typed embeddable, returning the set of parameter names it
* found (which the caller must persist as {@link ProductPendingEditSubmittedField} rows - this is
* how the manifest preserves "field omitted" vs "field explicitly null" vs "field has a value").
* Each writeXxxToJson method is the exact inverse, used at approval time to rebuild a JsonObject
* equivalent to what the maker originally submitted, before handing it to the unchanged
* update()/updateProduct() methods via JsonCommand.fromJsonElement(...).
*
* Share Product's field set is largely disjoint from this shared core and is mapped separately.
*/
public final class ProductPendingEditFieldMapper {
private ProductPendingEditFieldMapper() {}
// ---- Savings/Current/FD/RD shared core fields ----------------------------------------------
public static Set<String> populateCoreFields(final JsonCommand command, final SavingsCoreEditFields f) {
final Set<String> present = new HashSet<>();
if (command.hasParameter("name")) {
f.setName(command.stringValueOfParameterNamed("name"));
present.add("name");
}
if (command.hasParameter("shortName")) {
f.setShortName(command.stringValueOfParameterNamed("shortName"));
present.add("shortName");
}
if (command.hasParameter("description")) {
f.setDescription(command.stringValueOfParameterNamed("description"));
present.add("description");
}
if (command.hasParameter("currencyCode")) {
f.setCurrencyCode(command.stringValueOfParameterNamed("currencyCode"));
present.add("currencyCode");
}
if (command.hasParameter("digitsAfterDecimal")) {
f.setDigitsAfterDecimal(command.integerValueOfParameterNamed("digitsAfterDecimal"));
present.add("digitsAfterDecimal");
}
if (command.hasParameter("inMultiplesOf")) {
f.setInMultiplesOf(command.integerValueOfParameterNamed("inMultiplesOf"));
present.add("inMultiplesOf");
}
if (command.hasParameter("accountingRule")) {
f.setAccountingRule(command.integerValueOfParameterNamed("accountingRule"));
present.add("accountingRule");
}
if (command.hasParameter("nominalAnnualInterestRate")) {
f.setNominalAnnualInterestRate(command.bigDecimalValueOfParameterNamed("nominalAnnualInterestRate"));
present.add("nominalAnnualInterestRate");
}
if (command.hasParameter("interestCompoundingPeriodType")) {
f.setInterestCompoundingPeriodType(command.integerValueOfParameterNamed("interestCompoundingPeriodType"));
present.add("interestCompoundingPeriodType");
}
if (command.hasParameter("interestPostingPeriodType")) {
f.setInterestPostingPeriodType(command.integerValueOfParameterNamed("interestPostingPeriodType"));
present.add("interestPostingPeriodType");
}
if (command.hasParameter("interestCalculationType")) {
f.setInterestCalculationType(command.integerValueOfParameterNamed("interestCalculationType"));
present.add("interestCalculationType");
}
if (command.hasParameter("interestCalculationDaysInYearType")) {
f.setInterestCalculationDaysInYearType(command.integerValueOfParameterNamed("interestCalculationDaysInYearType"));
present.add("interestCalculationDaysInYearType");
}
if (command.hasParameter("minRequiredOpeningBalance")) {
f.setMinRequiredOpeningBalance(command.bigDecimalValueOfParameterNamed("minRequiredOpeningBalance"));
present.add("minRequiredOpeningBalance");
}
if (command.hasParameter("lockinPeriodFrequency")) {
f.setLockinPeriodFrequency(command.integerValueOfParameterNamed("lockinPeriodFrequency"));
present.add("lockinPeriodFrequency");
}
if (command.hasParameter("lockinPeriodFrequencyType")) {
f.setLockinPeriodFrequencyType(command.integerValueOfParameterNamed("lockinPeriodFrequencyType"));
present.add("lockinPeriodFrequencyType");
}
if (command.hasParameter("withdrawalFeeForTransfers")) {
f.setWithdrawalFeeForTransfers(command.booleanObjectValueOfParameterNamed("withdrawalFeeForTransfers"));
present.add("withdrawalFeeForTransfers");
}
if (command.hasParameter("allowOverdraft")) {
f.setAllowOverdraft(command.booleanObjectValueOfParameterNamed("allowOverdraft"));
present.add("allowOverdraft");
}
if (command.hasParameter("overdraftLimit")) {
f.setOverdraftLimit(command.bigDecimalValueOfParameterNamed("overdraftLimit"));
present.add("overdraftLimit");
}
if (command.hasParameter("nominalAnnualInterestRateOverdraft")) {
f.setNominalAnnualInterestRateOverdraft(command.bigDecimalValueOfParameterNamed("nominalAnnualInterestRateOverdraft"));
present.add("nominalAnnualInterestRateOverdraft");
}
if (command.hasParameter("minOverdraftForInterestCalculation")) {
f.setMinOverdraftForInterestCalculation(command.bigDecimalValueOfParameterNamed("minOverdraftForInterestCalculation"));
present.add("minOverdraftForInterestCalculation");
}
if (command.hasParameter("enforceMinRequiredBalance")) {
f.setEnforceMinRequiredBalance(command.booleanObjectValueOfParameterNamed("enforceMinRequiredBalance"));
present.add("enforceMinRequiredBalance");
}
if (command.hasParameter("minRequiredBalance")) {
f.setMinRequiredBalance(command.bigDecimalValueOfParameterNamed("minRequiredBalance"));
present.add("minRequiredBalance");
}
if (command.hasParameter("lienAllowed")) {
f.setLienAllowed(command.booleanObjectValueOfParameterNamed("lienAllowed"));
present.add("lienAllowed");
}
if (command.hasParameter("maxAllowedLienLimit")) {
f.setMaxAllowedLienLimit(command.bigDecimalValueOfParameterNamed("maxAllowedLienLimit"));
present.add("maxAllowedLienLimit");
}
if (command.hasParameter("minBalanceForInterestCalculation")) {
f.setMinBalanceForInterestCalculation(command.bigDecimalValueOfParameterNamed("minBalanceForInterestCalculation"));
present.add("minBalanceForInterestCalculation");
}
if (command.hasParameter("withHoldTax")) {
f.setWithHoldTax(command.booleanObjectValueOfParameterNamed("withHoldTax"));
present.add("withHoldTax");
}
if (command.hasParameter("taxGroupId")) {
f.setTaxGroupId(command.longValueOfParameterNamed("taxGroupId"));
present.add("taxGroupId");
}
if (command.hasParameter("otherGlMappings")) {
f.setOtherGlMappings(command.stringValueOfParameterNamed("otherGlMappings"));
present.add("otherGlMappings");
}
if (command.hasParameter("isDormancyTrackingActive")) {
f.setIsDormancyTrackingActive(command.booleanObjectValueOfParameterNamed("isDormancyTrackingActive"));
present.add("isDormancyTrackingActive");
}
if (command.hasParameter("daysToInactive")) {
f.setDaysToInactive(command.integerValueOfParameterNamed("daysToInactive"));
present.add("daysToInactive");
}
if (command.hasParameter("daysToDormancy")) {
f.setDaysToDormancy(command.integerValueOfParameterNamed("daysToDormancy"));
present.add("daysToDormancy");
}
if (command.hasParameter("daysToEscheat")) {
f.setDaysToEscheat(command.integerValueOfParameterNamed("daysToEscheat"));
present.add("daysToEscheat");
}
if (command.hasParameter("accountType")) {
f.setAccountType(command.stringValueOfParameterNamed("accountType"));
present.add("accountType");
}
if (command.hasParameter("customerSegment")) {
f.setCustomerSegment(command.stringValueOfParameterNamed("customerSegment"));
present.add("customerSegment");
}
if (command.hasParameter("productCode")) {
f.setProductCode(command.stringValueOfParameterNamed("productCode"));
present.add("productCode");
}
if (command.hasParameter("glAccountId")) {
f.setGlAccountId(command.longValueOfParameterNamed("glAccountId"));
present.add("glAccountId");
}
if (command.hasParameter("effectiveDate")) {
f.setEffectiveDate(command.localDateValueOfParameterNamed("effectiveDate"));
present.add("effectiveDate");
}
if (command.hasParameter("effectiveTo")) {
f.setEffectiveTo(command.localDateValueOfParameterNamed("effectiveTo"));
present.add("effectiveTo");
}
if (command.hasParameter("endDate")) {
f.setEndDate(command.localDateValueOfParameterNamed("endDate"));
present.add("endDate");
}
if (command.hasParameter("introducerRequired")) {
f.setIntroducerRequired(command.booleanObjectValueOfParameterNamed("introducerRequired"));
present.add("introducerRequired");
}
if (command.hasParameter("shareLinkingEnabled")) {
f.setShareLinkingEnabled(command.booleanObjectValueOfParameterNamed("shareLinkingEnabled"));
present.add("shareLinkingEnabled");
}
if (command.hasParameter("nominationFacility")) {
f.setNominationFacility(command.booleanObjectValueOfParameterNamed("nominationFacility"));
present.add("nominationFacility");
}
if (command.hasParameter("dormancyAlert")) {
f.setDormancyAlert(command.booleanObjectValueOfParameterNamed("dormancyAlert"));
present.add("dormancyAlert");
}
if (command.hasParameter("interestCalculateSelector")) {
f.setInterestCalculateSelector(command.integerValueOfParameterNamed("interestCalculateSelector"));
present.add("interestCalculateSelector");
}
if (command.hasParameter("averagePeriod")) {
f.setAveragePeriod(command.stringValueOfParameterNamed("averagePeriod"));
present.add("averagePeriod");
}
if (command.hasParameter("autoPost")) {
f.setAutoPost(command.booleanObjectValueOfParameterNamed("autoPost"));
present.add("autoPost");
}
if (command.hasParameter("frequencyFixDay")) {
f.setFrequencyFixDay(command.integerValueOfParameterNamed("frequencyFixDay"));
present.add("frequencyFixDay");
}
if (command.hasParameter("lastInterestDate")) {
f.setLastInterestDate(command.localDateValueOfParameterNamed("lastInterestDate"));
present.add("lastInterestDate");
}
if (command.hasParameter("enableLockinPeriod")) {
f.setEnableLockinPeriod(command.booleanObjectValueOfParameterNamed("enableLockinPeriod"));
present.add("enableLockinPeriod");
}
if (command.hasParameter("balanceFrequency")) {
f.setBalanceFrequency(command.stringValueOfParameterNamed("balanceFrequency"));
present.add("balanceFrequency");
}
if (command.hasParameter("accountClosedZeroBalanceInDayClosing")) {
f.setAccountClosedZeroBalanceInDayClosing(command.booleanObjectValueOfParameterNamed("accountClosedZeroBalanceInDayClosing"));
present.add("accountClosedZeroBalanceInDayClosing");
}
if (command.hasParameter("balanceSwapOption")) {
f.setBalanceSwapOption(command.booleanObjectValueOfParameterNamed("balanceSwapOption"));
present.add("balanceSwapOption");
}
if (command.hasParameter("msme")) {
f.setMsme(command.booleanObjectValueOfParameterNamed("msme"));
present.add("msme");
}
if (command.hasParameter("autoDebitFacility")) {
f.setAutoDebitFacility(command.booleanObjectValueOfParameterNamed("autoDebitFacility"));
present.add("autoDebitFacility");
}
if (command.hasParameter("loanFacility")) {
f.setLoanFacility(command.booleanObjectValueOfParameterNamed("loanFacility"));
present.add("loanFacility");
}
if (command.hasParameter("prematureWithdrawalOption")) {
f.setPrematureWithdrawalOption(command.booleanObjectValueOfParameterNamed("prematureWithdrawalOption"));
present.add("prematureWithdrawalOption");
}
if (command.hasParameter("tenureFlexibility")) {
f.setTenureFlexibility(command.booleanObjectValueOfParameterNamed("tenureFlexibility"));
present.add("tenureFlexibility");
}
if (command.hasParameter("lienMarked")) {
f.setLienMarked(command.booleanObjectValueOfParameterNamed("lienMarked"));
present.add("lienMarked");
}
if (command.hasParameter("maturityInstructions")) {
f.setMaturityInstructions(command.booleanObjectValueOfParameterNamed("maturityInstructions"));
present.add("maturityInstructions");
}
if (command.hasParameter("creditMaturityProceeds")) {
f.setCreditMaturityProceeds(command.booleanObjectValueOfParameterNamed("creditMaturityProceeds"));
present.add("creditMaturityProceeds");
}
if (command.hasParameter("penaltyGracePeriodMissedInstallments")) {
f.setPenaltyGracePeriodMissedInstallments(command.booleanObjectValueOfParameterNamed("penaltyGracePeriodMissedInstallments"));
present.add("penaltyGracePeriodMissedInstallments");
}
if (command.hasParameter("panelRequestApplicable")) {
f.setPanelRequestApplicable(command.booleanObjectValueOfParameterNamed("panelRequestApplicable"));
present.add("panelRequestApplicable");
}
if (command.hasParameter("noPenaltyNomineeHeirClosure")) {
f.setNoPenaltyNomineeHeirClosure(command.booleanObjectValueOfParameterNamed("noPenaltyNomineeHeirClosure"));
present.add("noPenaltyNomineeHeirClosure");
}
if (command.hasParameter("deceasedSettlement")) {
f.setDeceasedSettlement(command.booleanObjectValueOfParameterNamed("deceasedSettlement"));
present.add("deceasedSettlement");
}
if (command.hasParameter("allowDebitTxnAfterDueDate")) {
f.setAllowDebitTxnAfterDueDate(command.booleanObjectValueOfParameterNamed("allowDebitTxnAfterDueDate"));
present.add("allowDebitTxnAfterDueDate");
}
return present;
}
public static void writeCoreFieldsToJson(final JsonObject root, final SavingsCoreEditFields f, final Set<String> submitted) {
putString(root, submitted, "name", f.getName());
putString(root, submitted, "shortName", f.getShortName());
putString(root, submitted, "description", f.getDescription());
putString(root, submitted, "currencyCode", f.getCurrencyCode());
putNumber(root, submitted, "digitsAfterDecimal", f.getDigitsAfterDecimal());
putNumber(root, submitted, "inMultiplesOf", f.getInMultiplesOf());
putNumber(root, submitted, "accountingRule", f.getAccountingRule());
putNumber(root, submitted, "nominalAnnualInterestRate", f.getNominalAnnualInterestRate());
putNumber(root, submitted, "interestCompoundingPeriodType", f.getInterestCompoundingPeriodType());
putNumber(root, submitted, "interestPostingPeriodType", f.getInterestPostingPeriodType());
putNumber(root, submitted, "interestCalculationType", f.getInterestCalculationType());
putNumber(root, submitted, "interestCalculationDaysInYearType", f.getInterestCalculationDaysInYearType());
putNumber(root, submitted, "minRequiredOpeningBalance", f.getMinRequiredOpeningBalance());
putNumber(root, submitted, "lockinPeriodFrequency", f.getLockinPeriodFrequency());
putNumber(root, submitted, "lockinPeriodFrequencyType", f.getLockinPeriodFrequencyType());
putBoolean(root, submitted, "withdrawalFeeForTransfers", f.getWithdrawalFeeForTransfers());
putBoolean(root, submitted, "allowOverdraft", f.getAllowOverdraft());
putNumber(root, submitted, "overdraftLimit", f.getOverdraftLimit());
putNumber(root, submitted, "nominalAnnualInterestRateOverdraft", f.getNominalAnnualInterestRateOverdraft());
putNumber(root, submitted, "minOverdraftForInterestCalculation", f.getMinOverdraftForInterestCalculation());
putBoolean(root, submitted, "enforceMinRequiredBalance", f.getEnforceMinRequiredBalance());
putNumber(root, submitted, "minRequiredBalance", f.getMinRequiredBalance());
putBoolean(root, submitted, "lienAllowed", f.getLienAllowed());
putNumber(root, submitted, "maxAllowedLienLimit", f.getMaxAllowedLienLimit());
putNumber(root, submitted, "minBalanceForInterestCalculation", f.getMinBalanceForInterestCalculation());
putBoolean(root, submitted, "withHoldTax", f.getWithHoldTax());
putNumber(root, submitted, "taxGroupId", f.getTaxGroupId());
putString(root, submitted, "otherGlMappings", f.getOtherGlMappings());
putBoolean(root, submitted, "isDormancyTrackingActive", f.getIsDormancyTrackingActive());
putNumber(root, submitted, "daysToInactive", f.getDaysToInactive());
putNumber(root, submitted, "daysToDormancy", f.getDaysToDormancy());
putNumber(root, submitted, "daysToEscheat", f.getDaysToEscheat());
putString(root, submitted, "accountType", f.getAccountType());
putString(root, submitted, "customerSegment", f.getCustomerSegment());
putString(root, submitted, "productCode", f.getProductCode());
putNumber(root, submitted, "glAccountId", f.getGlAccountId());
putDate(root, submitted, "effectiveDate", f.getEffectiveDate());
putDate(root, submitted, "effectiveTo", f.getEffectiveTo());
putDate(root, submitted, "endDate", f.getEndDate());
putBoolean(root, submitted, "introducerRequired", f.getIntroducerRequired());
putBoolean(root, submitted, "shareLinkingEnabled", f.getShareLinkingEnabled());
putBoolean(root, submitted, "nominationFacility", f.getNominationFacility());
putBoolean(root, submitted, "dormancyAlert", f.getDormancyAlert());
putNumber(root, submitted, "interestCalculateSelector", f.getInterestCalculateSelector());
putString(root, submitted, "averagePeriod", f.getAveragePeriod());
putBoolean(root, submitted, "autoPost", f.getAutoPost());
putNumber(root, submitted, "frequencyFixDay", f.getFrequencyFixDay());
putDate(root, submitted, "lastInterestDate", f.getLastInterestDate());
putBoolean(root, submitted, "enableLockinPeriod", f.getEnableLockinPeriod());
putString(root, submitted, "balanceFrequency", f.getBalanceFrequency());
putBoolean(root, submitted, "accountClosedZeroBalanceInDayClosing", f.getAccountClosedZeroBalanceInDayClosing());
putBoolean(root, submitted, "balanceSwapOption", f.getBalanceSwapOption());
putBoolean(root, submitted, "msme", f.getMsme());
putBoolean(root, submitted, "autoDebitFacility", f.getAutoDebitFacility());
putBoolean(root, submitted, "loanFacility", f.getLoanFacility());
putBoolean(root, submitted, "prematureWithdrawalOption", f.getPrematureWithdrawalOption());
putBoolean(root, submitted, "tenureFlexibility", f.getTenureFlexibility());
putBoolean(root, submitted, "lienMarked", f.getLienMarked());
putBoolean(root, submitted, "maturityInstructions", f.getMaturityInstructions());
putBoolean(root, submitted, "creditMaturityProceeds", f.getCreditMaturityProceeds());
putBoolean(root, submitted, "penaltyGracePeriodMissedInstallments", f.getPenaltyGracePeriodMissedInstallments());
putBoolean(root, submitted, "panelRequestApplicable", f.getPanelRequestApplicable());
putBoolean(root, submitted, "noPenaltyNomineeHeirClosure", f.getNoPenaltyNomineeHeirClosure());
putBoolean(root, submitted, "deceasedSettlement", f.getDeceasedSettlement());
putBoolean(root, submitted, "allowDebitTxnAfterDueDate", f.getAllowDebitTxnAfterDueDate());
}
// ---- accountFacilityConfig (1:1 container) --------------------------------------------------
public static boolean populateFacilityConfig(final JsonCommand command, final FacilityConfigEditFields f) {
if (!command.hasParameter("accountFacilityConfig")) {
return false;
}
f.setAtmFacility(booleanOf(command, "accountFacilityConfig", "atmFacility"));
f.setAtmWithdrawalFacility(booleanOf(command, "accountFacilityConfig", "atmWithdrawalFacility"));
f.setUpiFacility(booleanOf(command, "accountFacilityConfig", "upiFacility"));
f.setPosTransactionsFacility(booleanOf(command, "accountFacilityConfig", "posTransactionsFacility"));
f.setChequeBook(booleanOf(command, "accountFacilityConfig", "chequeBook"));
f.setImpsFacility(booleanOf(command, "accountFacilityConfig", "impsFacility"));
f.setSmsAlerts(booleanOf(command, "accountFacilityConfig", "smsAlerts"));
f.setBbpsFacility(booleanOf(command, "accountFacilityConfig", "bbpsFacility"));
f.setInsuranceCover(booleanOf(command, "accountFacilityConfig", "insuranceCover"));
f.setBepgFacility(booleanOf(command, "accountFacilityConfig", "bepgFacility"));
f.setNeftFacility(booleanOf(command, "accountFacilityConfig", "neftFacility"));
f.setRtgsFacility(booleanOf(command, "accountFacilityConfig", "rtgsFacility"));
f.setInterBankTotalCapFacility(booleanOf(command, "accountFacilityConfig", "interBankTotalCapFacility"));
f.setWithdrawalFacility(booleanOf(command, "accountFacilityConfig", "withdrawalFacility"));
f.setDepositFacilityKyc(booleanOf(command, "accountFacilityConfig", "depositFacilityKyc"));
f.setVirtualDebitCard(booleanOf(command, "accountFacilityConfig", "virtualDebitCard"));
return true;
}
public static void writeFacilityConfigToJson(final JsonObject root, final FacilityConfigEditFields f, final boolean submitted) {
if (!submitted) {
return;
}
final JsonObject config = new JsonObject();
config.addProperty("atmFacility", f.getAtmFacility());
config.addProperty("atmWithdrawalFacility", f.getAtmWithdrawalFacility());
config.addProperty("upiFacility", f.getUpiFacility());
config.addProperty("posTransactionsFacility", f.getPosTransactionsFacility());
config.addProperty("chequeBook", f.getChequeBook());
config.addProperty("impsFacility", f.getImpsFacility());
config.addProperty("smsAlerts", f.getSmsAlerts());
config.addProperty("bbpsFacility", f.getBbpsFacility());
config.addProperty("insuranceCover", f.getInsuranceCover());
config.addProperty("bepgFacility", f.getBepgFacility());
config.addProperty("neftFacility", f.getNeftFacility());
config.addProperty("rtgsFacility", f.getRtgsFacility());
config.addProperty("interBankTotalCapFacility", f.getInterBankTotalCapFacility());
config.addProperty("withdrawalFacility", f.getWithdrawalFacility());
config.addProperty("depositFacilityKyc", f.getDepositFacilityKyc());
config.addProperty("virtualDebitCard", f.getVirtualDebitCard());
root.add("accountFacilityConfig", config);
}
// ---- depositLimitKyc (1:1 container, nested general/minor/smallSaving shape) ----------------
public static boolean populateDepositLimitKyc(final JsonCommand command, final DepositLimitKycEditFields f) {
if (!command.hasParameter("depositLimitKyc")) {
return false;
}
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("depositLimitKyc");
if (raw == null || !raw.isJsonObject()) {
return true;
}
final JsonObject kyc = raw.getAsJsonObject();
if (kyc.has("general") && kyc.get("general").isJsonObject()) {
final JsonObject general = kyc.getAsJsonObject("general");
f.setGeneralType(stringOrNull(general, "kycType"));
f.setGeneralLimit(stringOrNull(general, "limit"));
}
if (kyc.has("minor") && kyc.get("minor").isJsonObject()) {
final JsonObject minor = kyc.getAsJsonObject("minor");
f.setMinorType(stringOrNull(minor, "kycType"));
final String minorLimit = stringOrNull(minor, "limit");
f.setMinorLimit(minorLimit == null ? null : Long.valueOf(minorLimit));
}
if (kyc.has("smallSaving") && kyc.get("smallSaving").isJsonObject()) {
final JsonObject smallSaving = kyc.getAsJsonObject("smallSaving");
f.setSmallSavingType(stringOrNull(smallSaving, "kycType"));
final String smallSavingLimit = stringOrNull(smallSaving, "limit");
f.setSmallSavingLimit(smallSavingLimit == null ? null : Long.valueOf(smallSavingLimit));
}
return true;
}
public static void writeDepositLimitKycToJson(final JsonObject root, final DepositLimitKycEditFields f, final boolean submitted) {
if (!submitted) {
return;
}
final JsonObject kyc = new JsonObject();
final JsonObject general = new JsonObject();
general.addProperty("kycType", f.getGeneralType());
general.addProperty("limit", f.getGeneralLimit());
kyc.add("general", general);
final JsonObject minor = new JsonObject();
minor.addProperty("kycType", f.getMinorType());
if (f.getMinorLimit() != null) {
minor.addProperty("limit", f.getMinorLimit());
}
kyc.add("minor", minor);
final JsonObject smallSaving = new JsonObject();
smallSaving.addProperty("kycType", f.getSmallSavingType());
if (f.getSmallSavingLimit() != null) {
smallSaving.addProperty("limit", f.getSmallSavingLimit());
}
kyc.add("smallSaving", smallSaving);
root.add("depositLimitKyc", kyc);
}
// ---- GL scalar account mapping (1:1) ----------------------------------------------------------
public static boolean populateGlAccountMapping(final JsonCommand command, final GlAccountMappingEditFields f) {
boolean any = false;
any |= setIfPresent(command, "savingsReferenceAccountId", f::setSavingsReferenceAccountId);
any |= setIfPresent(command, "savingsControlAccountId", f::setSavingsControlAccountId);
any |= setIfPresent(command, "incomeFromFeeAccountId", f::setIncomeFromFeeAccountId);
any |= setIfPresent(command, "incomeFromPenaltyAccountId", f::setIncomeFromPenaltyAccountId);
any |= setIfPresent(command, "interestOnSavingsAccountId", f::setInterestOnSavingsAccountId);
any |= setIfPresent(command, "transfersInSuspenseAccountId", f::setTransfersInSuspenseAccountId);
any |= setIfPresent(command, "overdraftPortfolioControlId", f::setOverdraftPortfolioControlId);
any |= setIfPresent(command, "incomeFromInterestId", f::setIncomeFromInterestId);
any |= setIfPresent(command, "writeOffAccountId", f::setWriteOffAccountId);
any |= setIfPresent(command, "escheatLiabilityId", f::setEscheatLiabilityId);
any |= setIfPresent(command, "penaltiesReceivableAccountId", f::setPenaltiesReceivableAccountId);
any |= setIfPresent(command, "interestReceivableAccountId", f::setInterestReceivableAccountId);
any |= setIfPresent(command, "feesReceivableAccountId", f::setFeesReceivableAccountId);
any |= setIfPresent(command, "interestPayableAccountId", f::setInterestPayableAccountId);
return any;
}
public static void writeGlAccountMappingToJson(final JsonObject root, final GlAccountMappingEditFields f) {
putNumber(root, "savingsReferenceAccountId", f.getSavingsReferenceAccountId());
putNumber(root, "savingsControlAccountId", f.getSavingsControlAccountId());
putNumber(root, "incomeFromFeeAccountId", f.getIncomeFromFeeAccountId());
putNumber(root, "incomeFromPenaltyAccountId", f.getIncomeFromPenaltyAccountId());
putNumber(root, "interestOnSavingsAccountId", f.getInterestOnSavingsAccountId());
putNumber(root, "transfersInSuspenseAccountId", f.getTransfersInSuspenseAccountId());
putNumber(root, "overdraftPortfolioControlId", f.getOverdraftPortfolioControlId());
putNumber(root, "incomeFromInterestId", f.getIncomeFromInterestId());
putNumber(root, "writeOffAccountId", f.getWriteOffAccountId());
putNumber(root, "escheatLiabilityId", f.getEscheatLiabilityId());
putNumber(root, "penaltiesReceivableAccountId", f.getPenaltiesReceivableAccountId());
putNumber(root, "interestReceivableAccountId", f.getInterestReceivableAccountId());
putNumber(root, "feesReceivableAccountId", f.getFeesReceivableAccountId());
putNumber(root, "interestPayableAccountId", f.getInterestPayableAccountId());
}
// ---- charges (1:many, satellite table) --------------------------------------------------------
/** Returns null if "charges" wasn't present at all, else the (possibly empty) list of charge ids. */
public static List<Long> extractChargeIds(final JsonCommand command) {
if (!command.hasParameter("charges")) {
return null;
}
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("charges");
final List<Long> ids = new java.util.ArrayList<>();
if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
if (el.isJsonObject() && el.getAsJsonObject().has("id")) {
ids.add(el.getAsJsonObject().get("id").getAsLong());
}
}
}
return ids;
}
public static void writeChargesToJson(final JsonObject root, final List<ProductPendingEditCharge> charges) {
final JsonArray array = new JsonArray();
for (final ProductPendingEditCharge charge : charges) {
final JsonObject entry = new JsonObject();
entry.addProperty("id", charge.getChargeId());
array.add(entry);
}
root.add("charges", array);
}
// ---- accountFacilityLimits (1:many, satellite table) -------------------------------------------
public static List<ProductPendingEditFacilityLimit> extractFacilityLimits(final JsonCommand command, final ProductPendingEdit edit) {
if (!command.hasParameter("accountFacilityLimits")) {
return null;
}
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("accountFacilityLimits");
final List<ProductPendingEditFacilityLimit> limits = new java.util.ArrayList<>();
if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
final JsonObject o = el.getAsJsonObject();
limits.add(new ProductPendingEditFacilityLimit(edit, stringOrNull(o, "limitType"), stringOrNull(o, "generalLimit"),
stringOrNull(o, "minorAccountLimit"), stringOrNull(o, "smallSavingLimit")));
}
}
return limits;
}
public static void writeFacilityLimitsToJson(final JsonObject root, final List<ProductPendingEditFacilityLimit> limits) {
final JsonArray array = new JsonArray();
for (final ProductPendingEditFacilityLimit limit : limits) {
final JsonObject entry = new JsonObject();
entry.addProperty("limitType", limit.getLimitType());
entry.addProperty("generalLimit", limit.getGeneralLimit());
entry.addProperty("minorAccountLimit", limit.getMinorAccountLimit());
entry.addProperty("smallSavingLimit", limit.getSmallSavingLimit());
array.add(entry);
}
root.add("accountFacilityLimits", array);
}
// ---- GL array mappings (1:many, satellite table) -----------------------------------------------
public static List<ProductPendingEditGlMapping> extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) {
final List<ProductPendingEditGlMapping> mappings = new java.util.ArrayList<>();
boolean any = false;
any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL,
"paymentTypeId", mappings);
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", mappings);
any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId",
mappings);
return any ? mappings : null;
}
private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName,
final String mappingType, final String idFieldName, final List<ProductPendingEditGlMapping> out) {
if (!command.hasParameter(paramName)) {
return false;
}
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get(paramName);
if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
final JsonObject o = el.getAsJsonObject();
final Long idFieldValue = o.has(idFieldName) ? o.get(idFieldName).getAsLong() : null;
final Long glAccountId = o.has("glAccountId") ? o.get("glAccountId").getAsLong() : null;
final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null;
final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null;
out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId));
}
}
return true;
}
public static void writeGlArrayMappingsToJson(final JsonObject root, final List<ProductPendingEditGlMapping> mappings) {
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings",
"paymentTypeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId");
}
private static void writeGlArrayGroup(final JsonObject root, final List<ProductPendingEditGlMapping> mappings, final String type,
final String paramName, final String idFieldName) {
final JsonArray array = new JsonArray();
for (final ProductPendingEditGlMapping mapping : mappings) {
if (!type.equals(mapping.getMappingType())) {
continue;
}
final JsonObject entry = new JsonObject();
final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId();
entry.addProperty(idFieldName, idFieldValue);
entry.addProperty("glAccountId", mapping.getGlAccountId());
array.add(entry);
}
root.add(paramName, array);
}
// ---- small helpers -----------------------------------------------------------------------------
private static boolean booleanOf(final JsonCommand command, final String containerName, final String fieldName) {
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get(containerName);
if (raw == null || !raw.isJsonObject()) {
return false;
}
final JsonObject container = raw.getAsJsonObject();
return container.has(fieldName) && !container.get(fieldName).isJsonNull() && container.get(fieldName).getAsBoolean();
}
private static String stringOrNull(final JsonObject o, final String field) {
return (o.has(field) && !o.get(field).isJsonNull()) ? o.get(field).getAsString() : null;
}
private interface LongSetter {
void set(Long value);
}
private static boolean setIfPresent(final JsonCommand command, final String paramName, final LongSetter setter) {
if (!command.hasParameter(paramName)) {
return false;
}
setter.set(command.longValueOfParameterNamed(paramName));
return true;
}
private static void putString(final JsonObject root, final Set<String> submitted, final String name, final String value) {
if (submitted.contains(name)) {
root.addProperty(name, value);
}
}
private static void putNumber(final JsonObject root, final Set<String> submitted, final String name, final Number value) {
if (submitted.contains(name)) {
root.addProperty(name, value);
}
}
private static void putBoolean(final JsonObject root, final Set<String> submitted, final String name, final Boolean value) {
if (submitted.contains(name)) {
root.addProperty(name, value);
}
}
private static void putDate(final JsonObject root, final Set<String> submitted, final String name, final LocalDate value) {
if (submitted.contains(name)) {
root.addProperty(name, value == null ? null : value.toString());
}
}
private static void putNumber(final JsonObject root, final String name, final Number value) {
if (value != null) {
root.addProperty(name, value);
}
}
}
@@ -0,0 +1,70 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row per entry of the 3 array-shaped GL mappings (paymentChannelToFundSourceMappings,
* feeToIncomeAccountMappings, penaltyToIncomeAccountMappings), distinguished by mappingType.
*/
@Entity
@Table(name = "m_product_temp_gl_mapping")
@Getter
@Setter
@NoArgsConstructor
public class ProductPendingEditGlMapping extends AbstractPersistableCustom<Long> {
public static final String TYPE_PAYMENT_CHANNEL = "PAYMENT_CHANNEL";
public static final String TYPE_FEE = "FEE";
public static final String TYPE_PENALTY = "PENALTY";
@ManyToOne
@JoinColumn(name = "pending_edit_id", nullable = false)
private ProductPendingEdit pendingEdit;
@Column(name = "mapping_type", nullable = false)
private String mappingType;
@Column(name = "payment_type_id")
private Long paymentTypeId;
@Column(name = "charge_id")
private Long chargeId;
@Column(name = "gl_account_id", nullable = false)
private Long glAccountId;
public ProductPendingEditGlMapping(final ProductPendingEdit pendingEdit, final String mappingType, final Long paymentTypeId,
final Long chargeId, final Long glAccountId) {
this.pendingEdit = pendingEdit;
this.mappingType = mappingType;
this.paymentTypeId = paymentTypeId;
this.chargeId = chargeId;
this.glAccountId = glAccountId;
}
}
@@ -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.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductPendingEditGlMappingRepository extends JpaRepository<ProductPendingEditGlMapping, Long> {
List<ProductPendingEditGlMapping> findByPendingEditId(Long pendingEditId);
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/** One row per entry of Share Product's "marketPrice" array (create/update/delete-by-id semantics). */
@Entity
@Table(name = "m_product_temp_share_market_price")
@Getter
@Setter
@NoArgsConstructor
public class ProductPendingEditMarketPrice extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "pending_edit_id", nullable = false)
private ProductPendingEdit pendingEdit;
/** id of the existing m_share_product_market_price row this entry updates/deletes, null if new. */
@Column(name = "source_id")
private Long sourceId;
@Column(name = "delete_flag", nullable = false)
private boolean deleteFlag;
@Column(name = "from_date")
private LocalDate fromDate;
@Column(name = "share_value")
private BigDecimal shareValue;
public ProductPendingEditMarketPrice(final ProductPendingEdit pendingEdit, final Long sourceId, final boolean deleteFlag,
final LocalDate fromDate, final BigDecimal shareValue) {
this.pendingEdit = pendingEdit;
this.sourceId = sourceId;
this.deleteFlag = deleteFlag;
this.fromDate = fromDate;
this.shareValue = shareValue;
}
}
@@ -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.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductPendingEditMarketPriceRepository extends JpaRepository<ProductPendingEditMarketPrice, Long> {
List<ProductPendingEditMarketPrice> findByPendingEditId(Long pendingEditId);
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductPendingEditRepository extends JpaRepository<ProductPendingEdit, Long> {
Optional<ProductPendingEdit> findByProductTypeAndProductIdAndStatus(String productType, Long productId, String status);
List<ProductPendingEdit> findByProductTypeAndStatus(String productType, String status);
}
@@ -0,0 +1,56 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* Records which top-level JSON parameter names were actually present in the maker's original
* request. Typed nullable columns can't by themselves distinguish "field omitted" from "field
* explicitly sent as null" from "field has a value" - and every existing command.hasParameter(...)
* branch in the write-services and assemblers depends on that distinction - so this table is the
* relational replacement for the information a raw JSON blob used to carry implicitly.
*/
@Entity
@Table(name = "m_product_temp_submitted_field")
@Getter
@Setter
@NoArgsConstructor
public class ProductPendingEditSubmittedField extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "pending_edit_id", nullable = false)
private ProductPendingEdit pendingEdit;
@Column(name = "field_name", nullable = false)
private String fieldName;
public ProductPendingEditSubmittedField(final ProductPendingEdit pendingEdit, final String fieldName) {
this.pendingEdit = pendingEdit;
this.fieldName = fieldName;
}
}
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductPendingEditSubmittedFieldRepository extends JpaRepository<ProductPendingEditSubmittedField, Long> {
List<ProductPendingEditSubmittedField> findByPendingEditId(Long pendingEditId);
@Query("SELECT f.fieldName FROM ProductPendingEditSubmittedField f WHERE f.pendingEdit.id = :pendingEditId")
List<String> findFieldNamesByPendingEditId(@Param("pendingEditId") Long pendingEditId);
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Getter;
import lombok.Setter;
@Embeddable
@Getter
@Setter
public class RecurringDepositEditFields {
@Column(name = "is_mandatory_deposit")
private Boolean isMandatoryDeposit;
@Column(name = "allow_withdrawal")
private Boolean allowWithdrawal;
@Column(name = "adjust_advance_towards_future_payments")
private Boolean adjustAdvanceTowardsFuturePayments;
}
@@ -0,0 +1,234 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Lob;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.Setter;
/**
* Scalar edit fields shared by Savings, Current, Fixed Deposit and Recurring Deposit products
* (FD/RD extend the Savings field set). Column names are the snake_case of the JSON API parameter
* name each field stores, not the underlying product table's own column name.
*/
@Embeddable
@Getter
@Setter
public class SavingsCoreEditFields {
@Column(name = "name")
private String name;
@Column(name = "short_name")
private String shortName;
@Column(name = "description")
private String description;
@Column(name = "currency_code")
private String currencyCode;
@Column(name = "digits_after_decimal")
private Integer digitsAfterDecimal;
@Column(name = "in_multiples_of")
private Integer inMultiplesOf;
@Column(name = "accounting_rule")
private Integer accountingRule;
@Column(name = "nominal_annual_interest_rate")
private BigDecimal nominalAnnualInterestRate;
@Column(name = "interest_compounding_period_type")
private Integer interestCompoundingPeriodType;
@Column(name = "interest_posting_period_type")
private Integer interestPostingPeriodType;
@Column(name = "interest_calculation_type")
private Integer interestCalculationType;
@Column(name = "interest_calculation_days_in_year_type")
private Integer interestCalculationDaysInYearType;
@Column(name = "min_required_opening_balance")
private BigDecimal minRequiredOpeningBalance;
@Column(name = "lockin_period_frequency")
private Integer lockinPeriodFrequency;
@Column(name = "lockin_period_frequency_type")
private Integer lockinPeriodFrequencyType;
@Column(name = "withdrawal_fee_for_transfers")
private Boolean withdrawalFeeForTransfers;
@Column(name = "allow_overdraft")
private Boolean allowOverdraft;
@Column(name = "overdraft_limit")
private BigDecimal overdraftLimit;
@Column(name = "nominal_annual_interest_rate_overdraft")
private BigDecimal nominalAnnualInterestRateOverdraft;
@Column(name = "min_overdraft_for_interest_calculation")
private BigDecimal minOverdraftForInterestCalculation;
@Column(name = "enforce_min_required_balance")
private Boolean enforceMinRequiredBalance;
@Column(name = "min_required_balance")
private BigDecimal minRequiredBalance;
@Column(name = "lien_allowed")
private Boolean lienAllowed;
@Column(name = "max_allowed_lien_limit")
private BigDecimal maxAllowedLienLimit;
@Column(name = "min_balance_for_interest_calculation")
private BigDecimal minBalanceForInterestCalculation;
@Column(name = "with_hold_tax")
private Boolean withHoldTax;
@Column(name = "tax_group_id")
private Long taxGroupId;
@Lob
@Column(name = "other_gl_mappings")
private String otherGlMappings;
@Column(name = "is_dormancy_tracking_active")
private Boolean isDormancyTrackingActive;
@Column(name = "days_to_inactive")
private Integer daysToInactive;
@Column(name = "days_to_dormancy")
private Integer daysToDormancy;
@Column(name = "days_to_escheat")
private Integer daysToEscheat;
@Column(name = "account_type")
private String accountType;
@Column(name = "customer_segment")
private String customerSegment;
@Column(name = "product_code")
private String productCode;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "effective_date")
private LocalDate effectiveDate;
@Column(name = "effective_to")
private LocalDate effectiveTo;
@Column(name = "end_date")
private LocalDate endDate;
@Column(name = "introducer_required")
private Boolean introducerRequired;
@Column(name = "share_linking_enabled")
private Boolean shareLinkingEnabled;
@Column(name = "nomination_facility")
private Boolean nominationFacility;
@Column(name = "dormancy_alert")
private Boolean dormancyAlert;
@Column(name = "interest_calculate_selector")
private Integer interestCalculateSelector;
@Column(name = "average_period")
private String averagePeriod;
@Column(name = "auto_post")
private Boolean autoPost;
@Column(name = "frequency_fix_day")
private Integer frequencyFixDay;
@Column(name = "last_interest_date")
private LocalDate lastInterestDate;
@Column(name = "enable_lockin_period")
private Boolean enableLockinPeriod;
@Column(name = "balance_frequency")
private String balanceFrequency;
@Column(name = "account_closed_zero_balance_in_day_closing")
private Boolean accountClosedZeroBalanceInDayClosing;
@Column(name = "balance_swap_option")
private Boolean balanceSwapOption;
@Column(name = "msme")
private Boolean msme;
@Column(name = "auto_debit_facility")
private Boolean autoDebitFacility;
@Column(name = "loan_facility")
private Boolean loanFacility;
@Column(name = "premature_withdrawal_option")
private Boolean prematureWithdrawalOption;
@Column(name = "tenure_flexibility")
private Boolean tenureFlexibility;
@Column(name = "lien_marked")
private Boolean lienMarked;
@Column(name = "maturity_instructions")
private Boolean maturityInstructions;
@Column(name = "credit_maturity_proceeds")
private Boolean creditMaturityProceeds;
@Column(name = "penalty_grace_period_missed_installments")
private Boolean penaltyGracePeriodMissedInstallments;
@Column(name = "panel_request_applicable")
private Boolean panelRequestApplicable;
@Column(name = "no_penalty_nominee_heir_closure")
private Boolean noPenaltyNomineeHeirClosure;
@Column(name = "deceased_settlement")
private Boolean deceasedSettlement;
@Column(name = "allow_debit_txn_after_due_date")
private Boolean allowDebitTxnAfterDueDate;
}
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.Setter;
@Embeddable
@Getter
@Setter
public class ShareOnlyEditFields {
@Column(name = "total_shares")
private Long totalShares;
@Column(name = "total_shares_issued")
private Long totalSharesIssued;
@Column(name = "unit_price")
private BigDecimal unitPrice;
@Column(name = "minimum_shares")
private Long minimumShares;
@Column(name = "nominal_shares")
private Long nominalShares;
@Column(name = "maximum_shares")
private Long maximumShares;
@Column(name = "allow_dividend_calc_for_inactive_clients")
private Boolean allowDividendCalcForInactiveClients;
@Column(name = "minimum_active_period_for_dividends")
private Integer minimumActivePeriodForDividends;
@Column(name = "minimum_active_period_frequency_type")
private Integer minimumActivePeriodFrequencyType;
@Column(name = "share_lockin_period")
private Integer shareLockinPeriod;
@Column(name = "share_lockin_period_frequency_type")
private Integer shareLockinPeriodFrequencyType;
}
@@ -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.savings.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsProductEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public SavingsProductEditPendingApprovalException(final Long productId) {
super("error.msg.savingsproduct.edit.pending.approval",
"Savings product with identifier " + productId + " already has an edit pending approval", productId);
}
}
@@ -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.savings.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsProductNoPendingEditException extends AbstractPlatformDomainRuleException {
public SavingsProductNoPendingEditException(final Long productId) {
super("error.msg.savingsproduct.no.pending.edit",
"Savings product with identifier " + productId + " has no edit pending approval", productId);
}
}
@@ -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.savings.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsProductSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public SavingsProductSelfApprovalNotAllowedException(final Long productId) {
super("error.msg.savingsproduct.self.approval.not.allowed",
"The maker of the pending edit for savings product " + productId + " cannot also approve or reject it", productId);
}
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.savings.service;
import java.util.Collection;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.data.DepositProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
public interface DepositProductReadPlatformService {
@@ -32,4 +33,8 @@ public interface DepositProductReadPlatformService {
DepositProductData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId);
Collection<SavingsProductTempData> retrieveAllPendingEdits(DepositAccountType depositAccountType);
SavingsProductTempData retrievePendingEdit(DepositAccountType depositAccountType, Long productId);
}
@@ -28,4 +28,10 @@ public interface FixedDepositProductWritePlatformService {
CommandProcessingResult update(Long productId, JsonCommand command);
CommandProcessingResult delete(Long productId);
CommandProcessingResult submitEditForApproval(Long productId, JsonCommand command);
CommandProcessingResult approveEdit(Long productId);
CommandProcessingResult rejectEdit(Long productId, String rejectReason);
}
@@ -28,4 +28,10 @@ public interface RecurringDepositProductWritePlatformService {
CommandProcessingResult update(Long productId, JsonCommand command);
CommandProcessingResult delete(Long productId);
CommandProcessingResult submitEditForApproval(Long productId, JsonCommand command);
CommandProcessingResult approveEdit(Long productId);
CommandProcessingResult rejectEdit(Long productId, String rejectReason);
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.savings.service;
import java.util.Collection;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.data.SavingsProductTempData;
public interface SavingsProductReadPlatformService {
@@ -33,4 +34,8 @@ public interface SavingsProductReadPlatformService {
SavingsProductData retrieveOne(Long productId);
Collection<SavingsProductTempData> retrieveAllPendingEdits();
SavingsProductTempData retrievePendingEdit(Long productId);
}
@@ -28,4 +28,21 @@ public interface SavingsProductWritePlatformService {
CommandProcessingResult update(Long productId, JsonCommand command);
CommandProcessingResult delete(Long productId);
/**
* Submits an edit for approval instead of applying it directly. Fails if a pending edit already exists for this
* product (that pending row is itself the lock).
*/
CommandProcessingResult submitEditForApproval(Long productId, JsonCommand command);
/**
* Replays the stored pending edit through the normal update path and marks it approved. Fails if the approving
* user is the same user who submitted the edit.
*/
CommandProcessingResult approveEdit(Long productId);
/**
* Discards the pending edit. Fails if the rejecting user is the same user who submitted the edit.
*/
CommandProcessingResult rejectEdit(Long productId, String rejectReason);
}
@@ -39,4 +39,14 @@
<include file="parts/2014_add_is_mandatory_to_joint_holders.xml" relativeToChangelogFile="true" />
<include file="parts/2015_add_senior_citizen_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2016_add_share_linking_enabled_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2017_add_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2018_fix_saving_product_temp_lock_constraint.xml" relativeToChangelogFile="true" />
<include file="parts/2019_add_command_source_link_to_saving_product_temp.xml" relativeToChangelogFile="true" />
<include file="parts/2020_generalize_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2021_add_fd_rd_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2022_add_product_checker_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2023_fix_product_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2025_recreate_savings_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2026_add_pending_edit_typed_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2027_add_pending_edit_satellite_tables.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,85 @@
<?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">
<changeSet author="fineract" id="2017-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_savings_product_temp" />
</not>
</preConditions>
<createTable tableName="m_savings_product_temp">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="savings_product_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="sp_temp_unq_savings_product_id"
foreignKeyName="fk_sp_temp_savings_product_id" references="m_savings_product(id)" />
</column>
<column name="maker_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sp_temp_maker_id" references="m_appuser(id)" />
</column>
<column name="checker_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sp_temp_checker_id" references="m_appuser(id)" />
</column>
<column name="created_on" type="DATETIME">
<constraints nullable="false" />
</column>
<column name="checked_on" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="reject_reason" type="VARCHAR(500)">
<constraints nullable="true" />
</column>
<column name="proposed_payload_json" type="CLOB">
<constraints nullable="false" />
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="2017-2">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_SAVINGSPRODUCT'</sqlCheck>
</not>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_SAVINGSPRODUCT" />
<column name="entity_name" value="SAVINGSPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_SAVINGSPRODUCT" />
<column name="entity_name" value="SAVINGSPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?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">
<!--
The original unique constraint on savings_product_id blocked ANY future edit on a product forever, once a
single pending edit had been resolved - because resolved rows are kept (not deleted) for audit history.
The lock must only apply while status = PENDING. Unique constraints treat NULL as distinct in both
PostgreSQL and MySQL/MariaDB, so a nullable "lock key" column - set to the product id while pending, and
cleared back to NULL on approve/reject - gives a correct, cross-database concurrency-safe lock.
-->
<changeSet author="fineract" id="2018-1">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_savings_product_temp" columnName="savings_product_id" />
<not>
<columnExists tableName="m_savings_product_temp" columnName="pending_lock_key" />
</not>
</preConditions>
<dropUniqueConstraint tableName="m_savings_product_temp" constraintName="sp_temp_unq_savings_product_id" />
<addColumn tableName="m_savings_product_temp">
<column name="pending_lock_key" type="BIGINT">
<constraints nullable="true" />
</column>
</addColumn>
<sql>UPDATE m_savings_product_temp SET pending_lock_key = savings_product_id WHERE status = 'PENDING'</sql>
<addUniqueConstraint tableName="m_savings_product_temp" columnNames="pending_lock_key"
constraintName="sp_temp_unq_pending_lock_key" />
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?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">
<!--
Links each pending edit to the audit-only m_portfolio_command_source row created for it, so that
submit/approve/reject also appear in System -> Audit Trails even though this feature bypasses the native
maker-checker execution pipeline.
-->
<changeSet author="fineract" id="2019-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="command_source_id" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="command_source_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sp_temp_command_source_id"
references="m_portfolio_command_source(id)" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,107 @@
<?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">
<!--
Generalizes m_savings_product_temp (kept as one shared table, per requirement) so it can hold pending edits
for Current Account Products (m_current_product) and Share Products (m_share_product) as well as Savings
Products (m_savings_product) - not just savings. A single BIGINT "product_id" can't carry a normal FK
anymore since it now points at three different tables depending on product_type, so the old FK is dropped
(application code, not the DB, is now responsible for referential validity here).
pending_lock_key changes from BIGINT to VARCHAR because a raw numeric id is no longer unique across product
types (e.g. a current account product id=3 and a savings product id=3 must not collide). The new format is
"<PRODUCT_TYPE>:<product_id>", e.g. "SAVINGS:3" / "CURRENT:3" / "SHARE:3".
-->
<changeSet author="fineract" id="2020-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="product_type" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="product_type" type="VARCHAR(20)" defaultValue="SAVINGS">
<constraints nullable="false" />
</column>
</addColumn>
<dropForeignKeyConstraint baseTableName="m_savings_product_temp" constraintName="fk_sp_temp_savings_product_id" />
<renameColumn tableName="m_savings_product_temp" oldColumnName="savings_product_id" newColumnName="product_id"
columnDataType="BIGINT" />
<dropUniqueConstraint tableName="m_savings_product_temp" constraintName="sp_temp_unq_pending_lock_key" />
<addColumn tableName="m_savings_product_temp">
<column name="pending_lock_key_new" type="VARCHAR(40)">
<constraints nullable="true" />
</column>
</addColumn>
<sql dbms="postgresql">UPDATE m_savings_product_temp SET pending_lock_key_new = 'SAVINGS:' || product_id WHERE status = 'PENDING'</sql>
<sql dbms="mysql,mariadb">UPDATE m_savings_product_temp SET pending_lock_key_new = CONCAT('SAVINGS:', product_id) WHERE status = 'PENDING'</sql>
<dropColumn tableName="m_savings_product_temp" columnName="pending_lock_key" />
<renameColumn tableName="m_savings_product_temp" oldColumnName="pending_lock_key_new" newColumnName="pending_lock_key"
columnDataType="VARCHAR(40)" />
<addUniqueConstraint tableName="m_savings_product_temp" columnNames="pending_lock_key"
constraintName="sp_temp_unq_pending_lock_key" />
</changeSet>
<changeSet author="fineract" id="2020-2">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_CURRENTPRODUCT'</sqlCheck>
</not>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_CURRENTPRODUCT" />
<column name="entity_name" value="CURRENTPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_CURRENTPRODUCT" />
<column name="entity_name" value="CURRENTPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_SHAREPRODUCT" />
<column name="entity_name" value="SHAREPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_SHAREPRODUCT" />
<column name="entity_name" value="SHAREPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,61 @@
<?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">
<changeSet author="fineract" id="2021-1">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_FIXEDDEPOSITPRODUCT'</sqlCheck>
</not>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_FIXEDDEPOSITPRODUCT" />
<column name="entity_name" value="FIXEDDEPOSITPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_FIXEDDEPOSITPRODUCT" />
<column name="entity_name" value="FIXEDDEPOSITPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_RECURRINGDEPOSITPRODUCT" />
<column name="entity_name" value="RECURRINGDEPOSITPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_RECURRINGDEPOSITPRODUCT" />
<column name="entity_name" value="RECURRINGDEPOSITPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,103 @@
<?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">
<!--
AuditReadPlatformServiceImpl.retrieveAllEntriesToBeChecked() (backing GET /v1/makercheckers, the "Checker
Inbox" tab) shows a pending m_portfolio_command_source row to a "limited" checker only if their role holds
a permission whose action_name is "<ACTION>_CHECKER" and entity_name matches the row's entity_name (code
convention: "<ACTION>_<ENTITY>_CHECKER", matching how core Fineract permissions like UPDATE_ROLE_CHECKER
are seeded). Our custom pending-approval CommandSource rows for product edits (SAVINGSPRODUCT,
CURRENTPRODUCT, SHAREPRODUCT, FIXEDDEPOSITPRODUCT, RECURRINGDEPOSITPRODUCT) already satisfy the base
status=AWAITING_APPROVAL filter, but without these permission rows a limited checker's role has nothing to
match against, so the rows never show up in Checker Inbox - only in our own Pending Product Edits screen.
UPDATE_SAVINGSPRODUCT_CHECKER already exists as a native Fineract permission, so each row below is its own
changeset with its own precondition - one already-existing code must not block inserting the others.
-->
<changeSet author="fineract" id="2022-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SAVINGSPRODUCT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="UPDATE_SAVINGSPRODUCT_CHECKER" />
<column name="entity_name" value="SAVINGSPRODUCT" />
<column name="action_name" value="UPDATE_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2022-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_CURRENTPRODUCT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="UPDATE_CURRENTPRODUCT_CHECKER" />
<column name="entity_name" value="CURRENTPRODUCT" />
<column name="action_name" value="UPDATE_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2022-3">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SHAREPRODUCT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="UPDATE_SHAREPRODUCT_CHECKER" />
<column name="entity_name" value="SHAREPRODUCT" />
<column name="action_name" value="UPDATE_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2022-4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_FIXEDDEPOSITPRODUCT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="UPDATE_FIXEDDEPOSITPRODUCT_CHECKER" />
<column name="entity_name" value="FIXEDDEPOSITPRODUCT" />
<column name="action_name" value="UPDATE_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2022-5">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_RECURRINGDEPOSITPRODUCT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="UPDATE_RECURRINGDEPOSITPRODUCT_CHECKER" />
<column name="entity_name" value="RECURRINGDEPOSITPRODUCT" />
<column name="action_name" value="UPDATE_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,165 @@
<?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">
<!--
Changesets 2017-2, 2020-2 and 2021-1 used an inverted precondition - `<not><sqlCheck expectedResult="0">...`
actually means "run only if the row ALREADY exists", the opposite of the intended idempotent-insert guard.
On a fresh database (count=0) that precondition evaluates to false, so onFail="MARK_RAN" silently skipped
the insert entirely - the APPROVE_*/REJECT_* permissions for all five product types were marked as applied
in databasechangelog but never actually inserted. Those changesets are already recorded and must not be
edited (Liquibase would fail on checksum mismatch), so this migration re-inserts the missing rows under
fresh changeset ids, each with a correct (non-inverted) precondition and split per row so one already-
existing code can never block the others.
-->
<changeSet author="fineract" id="2023-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_SAVINGSPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_SAVINGSPRODUCT" />
<column name="entity_name" value="SAVINGSPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_SAVINGSPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_SAVINGSPRODUCT" />
<column name="entity_name" value="SAVINGSPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-3">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_CURRENTPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_CURRENTPRODUCT" />
<column name="entity_name" value="CURRENTPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_CURRENTPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_CURRENTPRODUCT" />
<column name="entity_name" value="CURRENTPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-5">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_SHAREPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_SHAREPRODUCT" />
<column name="entity_name" value="SHAREPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-6">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_SHAREPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_SHAREPRODUCT" />
<column name="entity_name" value="SHAREPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-7">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_FIXEDDEPOSITPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_FIXEDDEPOSITPRODUCT" />
<column name="entity_name" value="FIXEDDEPOSITPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-8">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_FIXEDDEPOSITPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_FIXEDDEPOSITPRODUCT" />
<column name="entity_name" value="FIXEDDEPOSITPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-9">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_RECURRINGDEPOSITPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="APPROVE_RECURRINGDEPOSITPRODUCT" />
<column name="entity_name" value="RECURRINGDEPOSITPRODUCT" />
<column name="action_name" value="APPROVE" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2023-10">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_RECURRINGDEPOSITPRODUCT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation" />
<column name="code" value="REJECT_RECURRINGDEPOSITPRODUCT" />
<column name="entity_name" value="RECURRINGDEPOSITPRODUCT" />
<column name="action_name" value="REJECT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,78 @@
<?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">
<!--
m_savings_product_temp was dropped by an experimental changeset that was later removed from source without
restoring the table. Recreates it with the final shape from the 2017/2018/2019/2020 migrations (guarded so
it's a no-op wherever the table already exists) so ProductPendingEditRepository and the isPendingApproval
subqueries in the product read services stop failing with "relation does not exist".
-->
<changeSet author="fineract" id="2025-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_savings_product_temp" />
</not>
</preConditions>
<createTable tableName="m_savings_product_temp">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="product_id" type="BIGINT">
<constraints nullable="false" />
</column>
<column name="product_type" type="VARCHAR(20)" defaultValue="SAVINGS">
<constraints nullable="false" />
</column>
<column name="maker_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sp_temp_maker_id" references="m_appuser(id)" />
</column>
<column name="checker_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sp_temp_checker_id" references="m_appuser(id)" />
</column>
<column name="created_on" type="DATETIME">
<constraints nullable="false" />
</column>
<column name="checked_on" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="reject_reason" type="VARCHAR(500)">
<constraints nullable="true" />
</column>
<column name="proposed_payload_json" type="CLOB">
<constraints nullable="false" />
</column>
<column name="pending_lock_key" type="VARCHAR(40)">
<constraints nullable="true" unique="true" uniqueConstraintName="sp_temp_unq_pending_lock_key" />
</column>
<column name="command_source_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sp_temp_command_source_id"
references="m_portfolio_command_source(id)" />
</column>
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,261 @@
<?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">
<!--
Adds typed columns to m_savings_product_temp so a pending product edit is stored as real,
individually-inspectable columns instead of only the opaque proposed_payload_json blob.
proposed_payload_json is kept (still written) for a safe deprecation window; nothing here
removes or alters any existing column. Every changeset is purely additive (addColumn only)
and guarded with a correctly-shaped precondition (<not><columnExists .../></not> - a
boolean precondition, unlike the historical bug in 2017-2/2020-2 which wrapped a numeric
<sqlCheck expectedResult="0"> in <not>, inverting its meaning).
Columns are grouped by which product type(s) apply them; unrelated columns are simply NULL
for a given row's product_type. Names are the snake_case of the JSON API parameter name the
column stores, not the source product table's own column name, since this table stores
proposed *edits* (parameter values), not a mirror of product-table storage.
-->
<!-- Group 1: fields shared across Savings, Current, FD and RD (FD/RD extend Savings) -->
<changeSet author="fineract" id="2026-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="name" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="name" type="VARCHAR(100)" />
<column name="short_name" type="VARCHAR(20)" />
<column name="description" type="VARCHAR(500)" />
<column name="currency_code" type="VARCHAR(3)" />
<column name="digits_after_decimal" type="INTEGER" />
<column name="in_multiples_of" type="INTEGER" />
<column name="accounting_rule" type="INTEGER" />
<column name="nominal_annual_interest_rate" type="DECIMAL(19,6)" />
<column name="interest_compounding_period_type" type="INTEGER" />
<column name="interest_posting_period_type" type="INTEGER" />
<column name="interest_calculation_type" type="INTEGER" />
<column name="interest_calculation_days_in_year_type" type="INTEGER" />
<column name="min_required_opening_balance" type="DECIMAL(19,6)" />
<column name="lockin_period_frequency" type="INTEGER" />
<column name="lockin_period_frequency_type" type="INTEGER" />
<column name="withdrawal_fee_for_transfers" type="BOOLEAN" />
<column name="allow_overdraft" type="BOOLEAN" />
<column name="overdraft_limit" type="DECIMAL(19,6)" />
<column name="nominal_annual_interest_rate_overdraft" type="DECIMAL(19,6)" />
<column name="min_overdraft_for_interest_calculation" type="DECIMAL(19,6)" />
<column name="enforce_min_required_balance" type="BOOLEAN" />
<column name="min_required_balance" type="DECIMAL(19,6)" />
<column name="lien_allowed" type="BOOLEAN" />
<column name="max_allowed_lien_limit" type="DECIMAL(19,6)" />
<column name="min_balance_for_interest_calculation" type="DECIMAL(19,6)" />
<column name="with_hold_tax" type="BOOLEAN" />
<column name="tax_group_id" type="BIGINT" />
<column name="other_gl_mappings" type="CLOB" />
<column name="is_dormancy_tracking_active" type="BOOLEAN" />
<column name="days_to_inactive" type="INTEGER" />
<column name="days_to_dormancy" type="INTEGER" />
<column name="days_to_escheat" type="INTEGER" />
<column name="account_type" type="VARCHAR(50)" />
<column name="customer_segment" type="VARCHAR(50)" />
<column name="product_code" type="VARCHAR(20)" />
<column name="gl_account_id" type="BIGINT" />
<column name="effective_date" type="DATE" />
<column name="effective_to" type="DATE" />
<column name="end_date" type="DATE" />
<column name="introducer_required" type="BOOLEAN" />
<column name="share_linking_enabled" type="BOOLEAN" />
<column name="nomination_facility" type="BOOLEAN" />
<column name="dormancy_alert" type="BOOLEAN" />
<column name="interest_calculate_selector" type="INTEGER" />
<column name="average_period" type="VARCHAR(20)" />
<column name="auto_post" type="BOOLEAN" />
<column name="frequency_fix_day" type="INTEGER" />
<column name="last_interest_date" type="DATE" />
<column name="enable_lockin_period" type="BOOLEAN" />
<column name="balance_frequency" type="VARCHAR(50)" />
<column name="account_closed_zero_balance_in_day_closing" type="BOOLEAN" />
<column name="balance_swap_option" type="BOOLEAN" />
<column name="msme" type="BOOLEAN" />
<column name="auto_debit_facility" type="BOOLEAN" />
<column name="loan_facility" type="BOOLEAN" />
<column name="premature_withdrawal_option" type="BOOLEAN" />
<column name="tenure_flexibility" type="BOOLEAN" />
<column name="lien_marked" type="BOOLEAN" />
<column name="maturity_instructions" type="BOOLEAN" />
<column name="credit_maturity_proceeds" type="BOOLEAN" />
<column name="penalty_grace_period_missed_installments" type="BOOLEAN" />
<column name="panel_request_applicable" type="BOOLEAN" />
<column name="no_penalty_nominee_heir_closure" type="BOOLEAN" />
<column name="deceased_settlement" type="BOOLEAN" />
<column name="allow_debit_txn_after_due_date" type="BOOLEAN" />
</addColumn>
</changeSet>
<!-- Group 2: Current-product-only fields -->
<changeSet author="fineract" id="2026-2">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="avg_balance_calc_period" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="avg_balance_calc_period" type="VARCHAR(10)" />
<column name="valid_for_period" type="VARCHAR(10)" />
<column name="od_account_closed_zero_balance_in_day_closing" type="BOOLEAN" />
</addColumn>
</changeSet>
<!-- Group 3: Share-product-only fields -->
<changeSet author="fineract" id="2026-3">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="total_shares" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="total_shares" type="BIGINT" />
<column name="total_shares_issued" type="BIGINT" />
<column name="unit_price" type="DECIMAL(19,6)" />
<column name="minimum_shares" type="BIGINT" />
<column name="nominal_shares" type="BIGINT" />
<column name="maximum_shares" type="BIGINT" />
<column name="allow_dividend_calc_for_inactive_clients" type="BOOLEAN" />
<column name="minimum_active_period_for_dividends" type="INTEGER" />
<column name="minimum_active_period_frequency_type" type="INTEGER" />
<column name="share_lockin_period" type="INTEGER" />
<column name="share_lockin_period_frequency_type" type="INTEGER" />
</addColumn>
</changeSet>
<!-- Group 4: Fixed-Deposit-only fields (also used by Recurring Deposit, which extends FD) -->
<changeSet author="fineract" id="2026-4">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="fd_type" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="fd_type" type="VARCHAR(20)" />
<column name="pre_closure_penal_applicable" type="BOOLEAN" />
<column name="pre_closure_penal_interest" type="DECIMAL(19,6)" />
<column name="pre_closure_penal_interest_on_type_id" type="INTEGER" />
<column name="min_deposit_term" type="INTEGER" />
<column name="max_deposit_term" type="INTEGER" />
<column name="min_deposit_term_type_id" type="INTEGER" />
<column name="max_deposit_term_type_id" type="INTEGER" />
<column name="in_multiples_of_deposit_term" type="INTEGER" />
<column name="in_multiples_of_deposit_term_type_id" type="INTEGER" />
<column name="min_deposit_amount" type="DECIMAL(19,6)" />
<column name="max_deposit_amount" type="DECIMAL(19,6)" />
<column name="deposit_amount" type="DECIMAL(19,6)" />
</addColumn>
</changeSet>
<!-- Group 5: Recurring-Deposit-only fields -->
<changeSet author="fineract" id="2026-5">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="is_mandatory_deposit" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="is_mandatory_deposit" type="BOOLEAN" />
<column name="allow_withdrawal" type="BOOLEAN" />
<column name="adjust_advance_towards_future_payments" type="BOOLEAN" />
</addColumn>
</changeSet>
<!-- Group 6: accountFacilityConfig (1:1 today - kept as flat columns, fc_ prefix) -->
<changeSet author="fineract" id="2026-6">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="fc_atm_facility" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="fc_atm_facility" type="BOOLEAN" />
<column name="fc_atm_withdrawal_facility" type="BOOLEAN" />
<column name="fc_upi_facility" type="BOOLEAN" />
<column name="fc_pos_transactions_facility" type="BOOLEAN" />
<column name="fc_cheque_book" type="BOOLEAN" />
<column name="fc_imps_facility" type="BOOLEAN" />
<column name="fc_sms_alerts" type="BOOLEAN" />
<column name="fc_bbps_facility" type="BOOLEAN" />
<column name="fc_insurance_cover" type="BOOLEAN" />
<column name="fc_bepg_facility" type="BOOLEAN" />
<column name="fc_neft_facility" type="BOOLEAN" />
<column name="fc_rtgs_facility" type="BOOLEAN" />
<column name="fc_inter_bank_total_cap_facility" type="BOOLEAN" />
<column name="fc_withdrawal_facility" type="BOOLEAN" />
<column name="fc_deposit_facility_kyc" type="BOOLEAN" />
<column name="fc_virtual_debit_card" type="BOOLEAN" />
</addColumn>
</changeSet>
<!-- Group 7: depositLimitKyc (1:1 today - kept as flat columns, kyc_ prefix) -->
<changeSet author="fineract" id="2026-7">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="kyc_general_type" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="kyc_general_type" type="VARCHAR(50)" />
<column name="kyc_general_limit" type="VARCHAR(50)" />
<column name="kyc_minor_type" type="VARCHAR(50)" />
<column name="kyc_minor_limit" type="BIGINT" />
<column name="kyc_small_saving_type" type="VARCHAR(50)" />
<column name="kyc_small_saving_limit" type="BIGINT" />
</addColumn>
</changeSet>
<!-- Group 8: GL account mapping scalar ids (1:1 today - kept as flat columns, gl_ prefix). No FK
constraints - the existing validator already checks GL account validity at submit time. -->
<changeSet author="fineract" id="2026-8">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product_temp" columnName="gl_savings_reference_account_id" />
</not>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="gl_savings_reference_account_id" type="BIGINT" />
<column name="gl_savings_control_account_id" type="BIGINT" />
<column name="gl_income_from_fee_account_id" type="BIGINT" />
<column name="gl_income_from_penalty_account_id" type="BIGINT" />
<column name="gl_interest_on_savings_account_id" type="BIGINT" />
<column name="gl_transfers_in_suspense_account_id" type="BIGINT" />
<column name="gl_overdraft_portfolio_control_id" type="BIGINT" />
<column name="gl_income_from_interest_id" type="BIGINT" />
<column name="gl_write_off_account_id" type="BIGINT" />
<column name="gl_escheat_liability_id" type="BIGINT" />
<column name="gl_penalties_receivable_account_id" type="BIGINT" />
<column name="gl_interest_receivable_account_id" type="BIGINT" />
<column name="gl_fees_receivable_account_id" type="BIGINT" />
<column name="gl_interest_payable_account_id" type="BIGINT" />
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,150 @@
<?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">
<!--
Satellite tables for the genuine one-to-many pieces of a pending product edit (charges,
facility limits, GL array mappings, share market prices) plus a "submitted field" manifest
that records which top-level JSON parameters were actually present in the maker's request.
The manifest table is not optional bookkeeping: raw JSON storage implicitly distinguishes
"field omitted" / "field explicitly null" / "field has a value", and every existing
command.hasParameter(...) branch in the write-services and assemblers depends on that
distinction. Typed nullable columns alone can't represent it, so this table is the
relational replacement for that information - one row per parameter name that was present
at submit time, per pending edit.
All tables reference m_savings_product_temp(id) - no cascade delete, since pending-edit
rows are never hard-deleted (only status-flipped) once resolved.
-->
<changeSet author="fineract" id="2027-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_product_temp_charge" />
</not>
</preConditions>
<createTable tableName="m_product_temp_charge">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="pending_edit_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_ptc_pending_edit_id" references="m_savings_product_temp(id)" />
</column>
<column name="charge_id" type="BIGINT">
<constraints nullable="false" />
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="2027-2">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_product_temp_facility_limit" />
</not>
</preConditions>
<createTable tableName="m_product_temp_facility_limit">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="pending_edit_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_ptfl_pending_edit_id" references="m_savings_product_temp(id)" />
</column>
<column name="limit_type" type="VARCHAR(100)">
<constraints nullable="false" />
</column>
<column name="general_limit" type="VARCHAR(50)" />
<column name="minor_account_limit" type="VARCHAR(50)" />
<column name="small_saving_limit" type="VARCHAR(50)" />
</createTable>
</changeSet>
<changeSet author="fineract" id="2027-3">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_product_temp_gl_mapping" />
</not>
</preConditions>
<createTable tableName="m_product_temp_gl_mapping">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="pending_edit_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_ptgm_pending_edit_id" references="m_savings_product_temp(id)" />
</column>
<!-- PAYMENT_CHANNEL / FEE / PENALTY -->
<column name="mapping_type" type="VARCHAR(30)">
<constraints nullable="false" />
</column>
<column name="payment_type_id" type="BIGINT" />
<column name="charge_id" type="BIGINT" />
<column name="gl_account_id" type="BIGINT">
<constraints nullable="false" />
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="2027-4">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_product_temp_share_market_price" />
</not>
</preConditions>
<createTable tableName="m_product_temp_share_market_price">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="pending_edit_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_ptsmp_pending_edit_id" references="m_savings_product_temp(id)" />
</column>
<column name="source_id" type="BIGINT" />
<column name="delete_flag" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="from_date" type="DATE" />
<column name="share_value" type="DECIMAL(19,6)" />
</createTable>
</changeSet>
<changeSet author="fineract" id="2027-5">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_product_temp_submitted_field" />
</not>
</preConditions>
<createTable tableName="m_product_temp_submitted_field">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="pending_edit_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_ptsf_pending_edit_id" references="m_savings_product_temp(id)" />
</column>
<column name="field_name" type="VARCHAR(100)">
<constraints nullable="false" />
</column>
</createTable>
<addUniqueConstraint tableName="m_product_temp_submitted_field" columnNames="pending_edit_id, field_name"
constraintName="ptsf_unq_pending_edit_field" />
</changeSet>
</databaseChangeLog>