From b0bfa386ceee47d7c6d95eb677f73d0613351ba6 Mon Sep 17 00:00:00 2001 From: keshav898966 Date: Fri, 3 Jul 2026 15:15:09 +0530 Subject: [PATCH] implemented a Maker-Checker workflow for Products, with approval-based updates and product locking to prevent concurrent edits --- .../savings/data/SavingsProductData.java | 3 + .../savings/data/SavingsProductTempData.java | 49 ++ ...ntProductEditPendingApprovalException.java | 29 + .../CurrentProductNoPendingEditException.java | 29 + ...roductSelfApprovalNotAllowedException.java | 29 + .../CurrentProductReadPlatformService.java | 5 + ...CurrentProductReadPlatformServiceImpl.java | 56 +- .../CurrentProductWritePlatformService.java | 17 + ...WritePlatformServiceJpaRepositoryImpl.java | 132 ++++ .../starter/CurrentProductConfiguration.java | 11 +- .../products/api/ProductsApiResource.java | 112 +++ .../ShareProductReadPlatformService.java | 5 + .../api/CurrentProductsApiResource.java | 77 +- .../api/FixedDepositProductsApiResource.java | 80 +- .../RecurringDepositProductsApiResource.java | 81 +- .../api/SavingsProductsApiResource.java | 91 ++- ...DepositProductReadPlatformServiceImpl.java | 69 +- ...WritePlatformServiceJpaRepositoryImpl.java | 132 ++++ ...WritePlatformServiceJpaRepositoryImpl.java | 132 ++++ ...SavingsProductReadPlatformServiceImpl.java | 58 +- ...WritePlatformServiceJpaRepositoryImpl.java | 236 ++++++ .../savings/starter/SavingsConfiguration.java | 29 +- ...reProductEditPendingApprovalException.java | 29 + .../ShareProductNoPendingEditException.java | 29 + ...roductSelfApprovalNotAllowedException.java | 29 + .../ShareProductReadPlatformServiceImpl.java | 53 ++ .../ShareProductWritePlatformService.java | 17 + ...WritePlatformServiceJpaRepositoryImpl.java | 130 ++++ .../starter/ShareProductsConfiguration.java | 10 +- .../savings/data/FixedDepositProductData.java | 2 + .../data/RecurringDepositProductData.java | 2 + .../savings/domain/CurrentOnlyEditFields.java | 39 + .../domain/DepositLimitKycEditFields.java | 54 ++ .../domain/FacilityConfigEditFields.java | 79 ++ .../domain/FixedDepositEditFields.java | 71 ++ .../domain/GlAccountMappingEditFields.java | 78 ++ .../savings/domain/ProductPendingEdit.java | 209 ++++++ .../domain/ProductPendingEditCharge.java | 50 ++ .../ProductPendingEditChargeRepository.java | 29 + .../ProductPendingEditFacilityLimit.java | 63 ++ ...uctPendingEditFacilityLimitRepository.java | 29 + .../domain/ProductPendingEditFieldMapper.java | 696 ++++++++++++++++++ .../domain/ProductPendingEditGlMapping.java | 70 ++ ...ProductPendingEditGlMappingRepository.java | 29 + .../domain/ProductPendingEditMarketPrice.java | 66 ++ ...oductPendingEditMarketPriceRepository.java | 29 + .../domain/ProductPendingEditRepository.java | 32 + .../ProductPendingEditSubmittedField.java | 56 ++ ...ctPendingEditSubmittedFieldRepository.java | 34 + .../domain/RecurringDepositEditFields.java | 39 + .../savings/domain/SavingsCoreEditFields.java | 234 ++++++ .../savings/domain/ShareOnlyEditFields.java | 64 ++ ...gsProductEditPendingApprovalException.java | 29 + .../SavingsProductNoPendingEditException.java | 29 + ...roductSelfApprovalNotAllowedException.java | 29 + .../DepositProductReadPlatformService.java | 5 + ...xedDepositProductWritePlatformService.java | 6 + ...ingDepositProductWritePlatformService.java | 6 + .../SavingsProductReadPlatformService.java | 5 + .../SavingsProductWritePlatformService.java | 17 + .../savings/parts/module-changelog-master.xml | 10 + .../2017_add_saving_product_temp_table.xml | 85 +++ ...ix_saving_product_temp_lock_constraint.xml | 54 ++ ...and_source_link_to_saving_product_temp.xml | 44 ++ .../2020_generalize_product_temp_table.xml | 107 +++ .../2021_add_fd_rd_approval_permissions.xml | 61 ++ .../2022_add_product_checker_permissions.xml | 103 +++ .../2023_fix_product_approval_permissions.xml | 165 +++++ ...25_recreate_savings_product_temp_table.xml | 78 ++ .../2026_add_pending_edit_typed_columns.xml | 261 +++++++ ...2027_add_pending_edit_satellite_tables.xml | 150 ++++ 71 files changed, 5031 insertions(+), 26 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductTempData.java create mode 100644 fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductEditPendingApprovalException.java create mode 100644 fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductNoPendingEditException.java create mode 100644 fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductSelfApprovalNotAllowedException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductEditPendingApprovalException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductNoPendingEditException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductSelfApprovalNotAllowedException.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositLimitKycEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/GlAccountMappingEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditCharge.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditChargeRepository.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimit.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimitRepository.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMapping.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMappingRepository.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPrice.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPriceRepository.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditRepository.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedField.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedFieldRepository.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ShareOnlyEditFields.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductEditPendingApprovalException.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductNoPendingEditException.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductSelfApprovalNotAllowedException.java create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2017_add_saving_product_temp_table.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2018_fix_saving_product_temp_lock_constraint.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2019_add_command_source_link_to_saving_product_temp.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2020_generalize_product_temp_table.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2021_add_fd_rd_approval_permissions.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2022_add_product_checker_permissions.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2023_fix_product_approval_permissions.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2025_recreate_savings_product_temp_table.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2026_add_pending_edit_typed_columns.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2027_add_pending_edit_satellite_tables.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java index 72380a6d8..0c74cde3c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java @@ -159,6 +159,9 @@ public final class SavingsProductData implements Serializable { @Getter @Setter private Map depositLimitKyc; @Getter @Setter private Map>> 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, diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductTempData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductTempData.java new file mode 100644 index 000000000..563388c89 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductTempData.java @@ -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 proposedChanges; + + public SavingsProductTempData() {} +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductEditPendingApprovalException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductEditPendingApprovalException.java new file mode 100644 index 000000000..a8862e301 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductEditPendingApprovalException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductNoPendingEditException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductNoPendingEditException.java new file mode 100644 index 000000000..a24e442f8 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductNoPendingEditException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductSelfApprovalNotAllowedException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..4a5c72a86 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/exception/CurrentProductSelfApprovalNotAllowedException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformService.java index 83e9bbd3a..929d2e12f 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformService.java @@ -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 retrieveAllForLookup(); SavingsProductData retrieveOne(Long productId); + + Collection retrieveAllPendingEdits(); + + SavingsProductTempData retrievePendingEdit(Long productId); } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java index 0075a381a..90b2d3299 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java @@ -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 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>() {}.getType())); + } + return data; + }; + + @Override + public Collection 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 { 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; } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformService.java index ba2f63af1..9c3accc66 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformService.java @@ -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); } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java index eed0dfb31..dbdc417cf 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java @@ -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 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) { diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java index 27afeccfe..503d63186 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java @@ -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); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/api/ProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/api/ProductsApiResource.java index 17708a967..fb62d7a65 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/api/ProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/api/ProductsApiResource.java @@ -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 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 toApiJsonSerializer; private final PlatformSecurityContext platformSecurityContext; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final ShareProductWritePlatformService shareProductWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final DefaultToApiJsonSerializer 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 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); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/service/ShareProductReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/service/ShareProductReadPlatformService.java index 9c9d15ee2..84d9b6289 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/service/ShareProductReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/products/service/ShareProductReadPlatformService.java @@ -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 getResponseDataParams(); Collection retrieveAllForLookup(); + + Collection retrieveAllPendingEdits(); + + SavingsProductTempData retrievePendingEdit(Long productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java index 06082b591..9cbe38744 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java @@ -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 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 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsApiResource.java index 9347d544b..a2ed17e02 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsApiResource.java @@ -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 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 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 }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositProductsApiResource.java index 4b0e6d305..28eb2581e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositProductsApiResource.java @@ -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) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString())) .create(); + private final RecurringDepositProductWritePlatformService recurringDepositProductWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final DefaultToApiJsonSerializer 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 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 }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResource.java index 90b49c5f1..06362799b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResource.java @@ -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 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 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 }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index 9a2cc2852..2eb1e82c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -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 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>() {}.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 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 { 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; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java index a05c538ab..23166a11d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java @@ -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 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java index 354c92cdf..363d6e4ed 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java @@ -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 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java index c1a702c84..7c1b9f3a0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java @@ -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 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 proposedChanges = GSON.fromJson(proposedPayloadJson, + new TypeToken>() {}.getType()); + data.setProposedChanges(proposedChanges); + } + return data; + }; + + @Override + public Collection 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 { 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java index b2aa0832a..a65a1e1c3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java @@ -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 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 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 facilityLimits = ProductPendingEditFieldMapper.extractFacilityLimits(command, + pendingEdit); + if (facilityLimits != null) { + this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "accountFacilityLimits")); + this.pendingEditFacilityLimitRepository.saveAll(facilityLimits); + } + + final List 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 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 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index fd0ebe860..d9537ad2f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductEditPendingApprovalException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductEditPendingApprovalException.java new file mode 100644 index 000000000..a607901b7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductEditPendingApprovalException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductNoPendingEditException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductNoPendingEditException.java new file mode 100644 index 000000000..9b25a9dc6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductNoPendingEditException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductSelfApprovalNotAllowedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..1f727cf8c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/exception/ShareProductSelfApprovalNotAllowedException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java index 7b3ac1274..425f6b2e8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java @@ -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 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>() {}.getType())); + } + return data; + }; + + @Override + public Collection 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 { @SuppressWarnings("unused") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformService.java index 7bb811165..17ecd2b93 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformService.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index 229106864..a0020f108 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -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 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", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java index 2cbe6469d..38a61e059 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java @@ -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); } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java index 4da5b1860..53c2e9199 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java @@ -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 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; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java index 62ae76c63..3c6537c83 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java @@ -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 v) { this.interestCalculationTypeOption = v; } public void setPeriodFrequencyOptions(Collection 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) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java new file mode 100644 index 000000000..0f3e8922b --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java @@ -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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositLimitKycEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositLimitKycEditFields.java new file mode 100644 index 000000000..9aa3640ea --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositLimitKycEditFields.java @@ -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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java new file mode 100644 index 000000000..7f9069144 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java @@ -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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java new file mode 100644 index 000000000..8699d91cb --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/GlAccountMappingEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/GlAccountMappingEditFields.java new file mode 100644 index 000000000..01fdc17b0 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/GlAccountMappingEditFields.java @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java new file mode 100644 index 000000000..dfc0c6347 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java @@ -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 { + + 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; + + /** + * ":" 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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditCharge.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditCharge.java new file mode 100644 index 000000000..b88d6c14b --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditCharge.java @@ -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 { + + @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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditChargeRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditChargeRepository.java new file mode 100644 index 000000000..0a0688b49 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditChargeRepository.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductPendingEditChargeRepository extends JpaRepository { + + List findByPendingEditId(Long pendingEditId); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimit.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimit.java new file mode 100644 index 000000000..55a0f271e --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimit.java @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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 { + + @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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimitRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimitRepository.java new file mode 100644 index 000000000..1b6577a37 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFacilityLimitRepository.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductPendingEditFacilityLimitRepository extends JpaRepository { + + List findByPendingEditId(Long pendingEditId); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java new file mode 100644 index 000000000..3eec82403 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -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 populateCoreFields(final JsonCommand command, final SavingsCoreEditFields f) { + final Set 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 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 extractChargeIds(final JsonCommand command) { + if (!command.hasParameter("charges")) { + return null; + } + final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("charges"); + final List 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 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 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 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 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 extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) { + final List 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 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 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 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 submitted, final String name, final String value) { + if (submitted.contains(name)) { + root.addProperty(name, value); + } + } + + private static void putNumber(final JsonObject root, final Set submitted, final String name, final Number value) { + if (submitted.contains(name)) { + root.addProperty(name, value); + } + } + + private static void putBoolean(final JsonObject root, final Set submitted, final String name, final Boolean value) { + if (submitted.contains(name)) { + root.addProperty(name, value); + } + } + + private static void putDate(final JsonObject root, final Set 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); + } + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMapping.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMapping.java new file mode 100644 index 000000000..0943f008d --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMapping.java @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.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 { + + 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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMappingRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMappingRepository.java new file mode 100644 index 000000000..12c2c0f9c --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditGlMappingRepository.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductPendingEditGlMappingRepository extends JpaRepository { + + List findByPendingEditId(Long pendingEditId); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPrice.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPrice.java new file mode 100644 index 000000000..6dc2955be --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPrice.java @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.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 { + + @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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPriceRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPriceRepository.java new file mode 100644 index 000000000..3bb8982c3 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditMarketPriceRepository.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductPendingEditMarketPriceRepository extends JpaRepository { + + List findByPendingEditId(Long pendingEditId); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditRepository.java new file mode 100644 index 000000000..4c4646bc7 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditRepository.java @@ -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 { + + Optional findByProductTypeAndProductIdAndStatus(String productType, Long productId, String status); + + List findByProductTypeAndStatus(String productType, String status); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedField.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedField.java new file mode 100644 index 000000000..24a0f1469 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedField.java @@ -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 { + + @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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedFieldRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedFieldRepository.java new file mode 100644 index 000000000..a2cf887bd --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditSubmittedFieldRepository.java @@ -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 { + + List findByPendingEditId(Long pendingEditId); + + @Query("SELECT f.fieldName FROM ProductPendingEditSubmittedField f WHERE f.pendingEdit.id = :pendingEditId") + List findFieldNamesByPendingEditId(@Param("pendingEditId") Long pendingEditId); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositEditFields.java new file mode 100644 index 000000000..489cc3a3e --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositEditFields.java @@ -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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java new file mode 100644 index 000000000..086038946 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java @@ -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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ShareOnlyEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ShareOnlyEditFields.java new file mode 100644 index 000000000..0fbb74896 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ShareOnlyEditFields.java @@ -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; +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductEditPendingApprovalException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductEditPendingApprovalException.java new file mode 100644 index 000000000..ccb526231 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductEditPendingApprovalException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductNoPendingEditException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductNoPendingEditException.java new file mode 100644 index 000000000..11c5bb2e0 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductNoPendingEditException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductSelfApprovalNotAllowedException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..47dcdf844 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsProductSelfApprovalNotAllowedException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.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); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformService.java index 76a8c5937..ba906bc70 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformService.java @@ -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 retrieveAllPendingEdits(DepositAccountType depositAccountType); + + SavingsProductTempData retrievePendingEdit(DepositAccountType depositAccountType, Long productId); + } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformService.java index 92ca7cd51..49a54be66 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformService.java @@ -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); } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformService.java index 81a5a2914..09e3d3375 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformService.java @@ -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); } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformService.java index 3f5e2e402..e5ca40ba2 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformService.java @@ -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 retrieveAllPendingEdits(); + + SavingsProductTempData retrievePendingEdit(Long productId); + } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformService.java index 0617cc3be..37c640fde 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformService.java @@ -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); } diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml index 2a50be19e..de44a7419 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml @@ -39,4 +39,14 @@ + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2017_add_saving_product_temp_table.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2017_add_saving_product_temp_table.xml new file mode 100644 index 000000000..a1b9befbc --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2017_add_saving_product_temp_table.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_SAVINGSPRODUCT' + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2018_fix_saving_product_temp_lock_constraint.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2018_fix_saving_product_temp_lock_constraint.xml new file mode 100644 index 000000000..4efb0d2f7 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2018_fix_saving_product_temp_lock_constraint.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + UPDATE m_savings_product_temp SET pending_lock_key = savings_product_id WHERE status = 'PENDING' + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2019_add_command_source_link_to_saving_product_temp.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2019_add_command_source_link_to_saving_product_temp.xml new file mode 100644 index 000000000..44a9e7d89 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2019_add_command_source_link_to_saving_product_temp.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2020_generalize_product_temp_table.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2020_generalize_product_temp_table.xml new file mode 100644 index 000000000..0892f7f79 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2020_generalize_product_temp_table.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_savings_product_temp SET pending_lock_key_new = 'SAVINGS:' || product_id WHERE status = 'PENDING' + UPDATE m_savings_product_temp SET pending_lock_key_new = CONCAT('SAVINGS:', product_id) WHERE status = 'PENDING' + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_CURRENTPRODUCT' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2021_add_fd_rd_approval_permissions.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2021_add_fd_rd_approval_permissions.xml new file mode 100644 index 000000000..8ae87c1d2 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2021_add_fd_rd_approval_permissions.xml @@ -0,0 +1,61 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_FIXEDDEPOSITPRODUCT' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2022_add_product_checker_permissions.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2022_add_product_checker_permissions.xml new file mode 100644 index 000000000..dbeb8f43b --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2022_add_product_checker_permissions.xml @@ -0,0 +1,103 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SAVINGSPRODUCT_CHECKER' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_CURRENTPRODUCT_CHECKER' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SHAREPRODUCT_CHECKER' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_FIXEDDEPOSITPRODUCT_CHECKER' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_RECURRINGDEPOSITPRODUCT_CHECKER' + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2023_fix_product_approval_permissions.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2023_fix_product_approval_permissions.xml new file mode 100644 index 000000000..44b9945a9 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2023_fix_product_approval_permissions.xml @@ -0,0 +1,165 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_SAVINGSPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_SAVINGSPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_CURRENTPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_CURRENTPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_SHAREPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_SHAREPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_FIXEDDEPOSITPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_FIXEDDEPOSITPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_RECURRINGDEPOSITPRODUCT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_RECURRINGDEPOSITPRODUCT' + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2025_recreate_savings_product_temp_table.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2025_recreate_savings_product_temp_table.xml new file mode 100644 index 000000000..69ff8cab2 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2025_recreate_savings_product_temp_table.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2026_add_pending_edit_typed_columns.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2026_add_pending_edit_typed_columns.xml new file mode 100644 index 000000000..b54bb8458 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2026_add_pending_edit_typed_columns.xml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2027_add_pending_edit_satellite_tables.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2027_add_pending_edit_satellite_tables.xml new file mode 100644 index 000000000..003966ce9 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2027_add_pending_edit_satellite_tables.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +