From 26d4c241c764454ac289cb4a8ed13840055255c8 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:54:03 +0530 Subject: [PATCH 01/39] fix(clerk-disbursement): validate savings withdrawal eligibility when a request is raised A clerk raising a disbursement request with a Dr leg against a savings account had no way to know the withdrawal would be refused (inactive account, still locked-in, or insufficient balance against its own minimum) until a manager tried to approve it and the whole request bounced back. Checks the same conditions SavingsAccount.withdraw() enforces at entry time instead, against the same columns, so the clerk sees the real reason immediately. Co-Authored-By: Claude Sonnet 5 --- .../ClerkDisbursementRequestApiResource.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 08d596d26..e75955127 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -177,6 +177,15 @@ public class ClerkDisbursementRequestApiResource { kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); } + // A Dr leg against a savings account is a withdrawal, and the clerk who raises it should + // not learn the account can't pay out until a manager tries to approve it and the whole + // request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would + // refuse this later -- inactive, closed, or still inside its lock-in period -- is checked + // here too, against the same columns, so the clerk sees the real reason at entry time. + if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType) + && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { + validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt); + } if ("Dr".equals(dbCr)) { totalDr = totalDr.add(entryAmt); } else { @@ -1179,6 +1188,51 @@ public class ClerkDisbursementRequestApiResource { .findFirst().orElse(null); } + /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ + private static final int SAVINGS_STATUS_ACTIVE = 300; + + /** + * The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not + * active, or still inside its lock-in period -- checked here against the same columns, so the + * clerk sees the real reason when the request is raised rather than a generic failure once a + * manager tries to approve it. Balance is checked too, against the account's own minimum, since + * an entry that can never post is no more usable sitting in the approval queue than one refused + * outright. + */ + private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived, + min_required_balance, enforce_min_required_balance + FROM m_savings_account WHERE id = ? + """, savingsAccountId); + if (rows.isEmpty()) { + return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere + } + final Map row = rows.get(0); + final String accountNo = (String) row.get("account_no"); + final Number statusEnum = (Number) row.get("status_enum"); + if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active."); + } + final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived"); + if (lockedUntil != null) { + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate lockedUntilDate = lockedUntil.toLocalDate(); + if (today.isBefore(lockedUntilDate)) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + + ": account is locked-in until " + lockedUntilDate + "."); + } + } + final BigDecimal balance = (BigDecimal) row.get("account_balance_derived"); + final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance")); + final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null + ? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO; + if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance (" + + balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ")."); + } + } + private void addDisbursementVoucherLines(final Map record, final JsonArray creditEntries, final Long sourceGlId, final String requestType) { From a7cb09f15cb3d30cc77fabd4084489b21f0d10fa Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:54:57 +0530 Subject: [PATCH 02/39] feat(fd): add lean FD maturity dashboard read endpoint New GET /v1/fixeddepositaccounts/maturity-dashboard: a narrow, purpose-built query for the FD Dashboard's maturity ladder (account/client/product identity, maturity date, deposit/maturity amounts) instead of routing through the general FixedDepositAccountsApiResource#retrieveAll, whose mapper joins six extra tables for audit/currency/tax fields this screen never shows. Adds the index migration (0571) the dashboard's WHERE clause needs -- neither m_savings_account.deposit_type_enum/status_enum nor m_deposit_account_term_and_preclosure.maturity_date had one before, so the endpoint would otherwise run a full table scan on top of being wide. Co-Authored-By: Claude Sonnet 5 --- ...edDepositMaturityDashboardApiResource.java | 96 +++++++++++++++++++ ..._deposit_type_status_and_maturity_date.xml | 36 +++++++ 2 files changed, 132 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java new file mode 100644 index 000000000..c1a1c8b51 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java @@ -0,0 +1,96 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder + * needs (account/client/product identity, maturity date, deposit and maturity amounts). + * + * Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that + * endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency, + * m_code_value and FIVE separate m_appuser joins (submitted/rejected/withdrawn/approved/activated/ + * closed-by), plus a correlated subquery against m_savings_account_transaction per row -- all of it + * needed for the single-account and full-list screens it serves, none of it needed here. Neither + * m_savings_account.deposit_type_enum/status_enum nor + * m_deposit_account_term_and_preclosure.maturity_date had an index before this feature (confirmed + * directly against pg_indexes), so that query was a full table scan on top of being wide. This + * endpoint selects only what the dashboard renders, against the narrow, now-indexed WHERE clause + * this feature added (see migration 0571). + */ +@Path("/v1/fixeddepositaccounts/maturity-dashboard") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: active FDs positioned against their maturity date.") +@RequiredArgsConstructor +public class FixedDepositMaturityDashboardApiResource { + + /** Fineract's DepositAccountType.FIXED_DEPOSIT. */ + private static final int FIXED_DEPOSIT = 200; + /** SavingsAccountStatusType.ACTIVE. */ + private static final int ACTIVE = 300; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new Gson(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String retrieve() { + // Same permission as the single-account/full-list FD endpoints -- this is a narrower read of + // the same resource, not a new one, so no new permission is introduced for it. + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "id", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.display_name AS "clientName", + sp.name AS "productName", + sp.fd_type AS "fdType", + TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate", + datp.deposit_amount AS "depositAmount", + datp.maturity_amount AS "maturityAmount", + datp.on_account_closure_enum AS "onClosureType", + sa.linked_account_id AS "linkedAccountId", + dptp.renewal_grace_period_days AS "renewalGracePeriodDays" + FROM m_savings_account sa + JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id + JOIN m_savings_product sp ON sp.id = sa.product_id + LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id + LEFT JOIN m_client c ON c.id = sa.client_id + WHERE sa.deposit_type_enum = ? AND sa.status_enum = ? + ORDER BY datp.maturity_date ASC + """, FIXED_DEPOSIT, ACTIVE); + + return this.gson.toJson(rows); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml new file mode 100644 index 000000000..4bf61c077 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + From ecfe8aa76a1dbbb8b0c7b410b8224d151d496a18 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:55:11 +0530 Subject: [PATCH 03/39] feat(fd): add Renewal Grace Period product field, both storage paths New per-FD-product "Renewal Grace Period (Days)" term field: null/0 (every existing product) keeps today's instant, same-night auto-renewal; a positive value is later read by the nightly maturity job and the manual Renew action to decide whether/how a matured FD waits for staff to act instead of renewing itself. Wired into the live product path end-to-end -- DepositsApiConstants param, DepositTermDetail column + diffing, DepositProductAssembler (create and update), DepositProductReadPlatformServiceImpl, FixedDepositProductData -- migration 0572 adds the column. Also wired into the maker-checker pending-edit mirror (m_saving_product_temp / FixedDepositEditFields / ProductPendingEditFieldMapper), a second, independent storage path a governed product edit uses -- without this the field silently dropped out of a checker's "Proposed changes" review even though it was submitted. Migration 0573 adds its column. Co-Authored-By: Claude Sonnet 5 --- .../savings/DepositsApiConstants.java | 9 ++++-- ...DepositProductReadPlatformServiceImpl.java | 2 ++ ...l_grace_period_to_deposit_product_term.xml | 26 +++++++++++++++++ ...al_grace_period_to_saving_product_temp.xml | 27 +++++++++++++++++ .../savings/data/FixedDepositProductData.java | 4 +++ .../domain/DepositProductAssembler.java | 20 ++++++++++--- .../savings/domain/DepositTermDetail.java | 29 +++++++++++++++++-- .../domain/FixedDepositEditFields.java | 3 ++ .../domain/ProductPendingEditFieldMapper.java | 5 ++++ 9 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index c5704438a..03c685961 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -143,6 +143,9 @@ public final class DepositsApiConstants { public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm"; public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId"; public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType"; + // Days after maturity a depositor still has to walk in and renew at the original maturity date -- + // null/0 means this product keeps the instant, same-night auto-renewal every FD product had before. + public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays"; public static final String depositAmountParamName = "depositAmount"; public static final String depositMinAmountParamName = "minDepositAmount"; @@ -248,11 +251,13 @@ public final class DepositsApiConstants { private static final Set DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName, - inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); + inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName, + renewalGracePeriodDaysParamName)); private static final Set DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName, - inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); + inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName, + renewalGracePeriodDaysParamName)); private static final Set RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName, 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 d431acb5b..24f1b6aaf 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 @@ -309,6 +309,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, "); sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, "); sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, "); + sqlBuilder.append("dptp.renewal_grace_period_days as renewalGracePeriodDays, "); sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, "); sqlBuilder.append("sp.customer_segment as customerSegment, "); sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, "); @@ -385,6 +386,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead // CBS Custom Fields result.setAccountType(rs.getString("accountType")); result.setFdType(rs.getString("fdType")); + result.setRenewalGracePeriodDays(JdbcSupport.getInteger(rs, "renewalGracePeriodDays")); result.setProductCode(rs.getString("productCode")); result.setCustomerSegment(rs.getString("customerSegment")); result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId")); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml new file mode 100644 index 000000000..cce65c650 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml new file mode 100644 index 000000000..119cefcc6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + 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 53c2e9199..88004012f 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 @@ -496,6 +496,7 @@ public final class FixedDepositProductData extends DepositProductData { private Boolean accountClosedZeroBalanceInDayClosing; private String balanceFrequency; private Boolean isPendingApproval; + private Integer renewalGracePeriodDays; // Template option setters public void setInterestCalculationTypeOption(Collection v) { this.interestCalculationTypeOption = v; } @@ -543,6 +544,7 @@ public final class FixedDepositProductData extends DepositProductData { public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; } public void setBalanceFrequency(String v) { this.balanceFrequency = v; } public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; } + public void setRenewalGracePeriodDays(Integer v) { this.renewalGracePeriodDays = v; } // CBS field getters public String getProductCode() { return productCode; } @@ -559,6 +561,7 @@ public final class FixedDepositProductData extends DepositProductData { public Long getDaysToInactive() { return daysToInactive; } public Long getDaysToDormancy() { return daysToDormancy; } public Long getDaysToEscheat() { return daysToEscheat; } + public Integer getRenewalGracePeriodDays() { return renewalGracePeriodDays; } public void copyCbsFieldsFrom(FixedDepositProductData src) { if (src == null) return; @@ -605,6 +608,7 @@ public final class FixedDepositProductData extends DepositProductData { this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing; this.balanceFrequency = src.balanceFrequency; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; + this.renewalGracePeriodDays = src.renewalGracePeriodDays; } private Boolean withdrawalFeeForTransfers; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java index 96d4996d1..64402d2e7 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java @@ -36,6 +36,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepo import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalApplicableParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestOnTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.currencyCodeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.descriptionParamName; @@ -473,8 +474,10 @@ public class DepositProductAssembler { final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); - return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, - inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, + maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + depositTermDetail.setRenewalGracePeriodDays(command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName)); + return depositTermDetail; } public DepositTermDetail assembleDepositTermDetail(final JsonCommand command, final DepositTermDetail prodDepositTermDetail) { @@ -531,8 +534,17 @@ public class DepositProductAssembler { final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); - return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, - inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + Integer renewalGracePeriodDays = null; + if (command.parameterExists(renewalGracePeriodDaysParamName)) { + renewalGracePeriodDays = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName); + } else if (prodDepositTermDetail != null) { + renewalGracePeriodDays = prodDepositTermDetail.renewalGracePeriodDays(); + } + + final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, + maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + depositTermDetail.setRenewalGracePeriodDays(renewalGracePeriodDays); + return depositTermDetail; } public DepositRecurringDetail assembleRecurringDetail(final JsonCommand command) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java index 360e64ada..e892c733d 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java @@ -24,6 +24,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepo import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermTypeIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -61,6 +62,14 @@ public class DepositTermDetail { @Column(name = "in_multiples_of_deposit_term_type_enum", nullable = true) private Integer inMultiplesOfDepositTermType; + // Days after maturity a depositor can still walk in and renew backdated to the original maturity + // date, at the currently active rate. Null/0 means this product keeps the instant, same-night + // auto-renewal every FD product had before this field existed -- see + // DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips the + // automatic renewal when this is a positive value. + @Column(name = "renewal_grace_period_days", nullable = true) + private Integer renewalGracePeriodDays; + public static DepositTermDetail createFrom(final Integer minDepositTerm, final Integer maxDepositTerm, final SavingsPeriodFrequencyType minDepositTermType, final SavingsPeriodFrequencyType maxDepositTermType, final Integer inMultiplesOfDepositTerm, final SavingsPeriodFrequencyType inMultiplesOfDepositTermType) { @@ -123,6 +132,12 @@ public class DepositTermDetail { this.inMultiplesOfDepositTermType = newValue; } + if (command.isChangeInIntegerParameterNamed(renewalGracePeriodDaysParamName, this.renewalGracePeriodDays)) { + final Integer newValue = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName); + actualChanges.put(renewalGracePeriodDaysParamName, newValue); + this.renewalGracePeriodDays = newValue; + } + if (isMinDepositTermGreaterThanMaxDepositTerm()) { baseDataValidator.parameter(minDepositTermParamName).value(this.minDepositTerm).failWithCode(".greater.than.maxDepositTerm", this.minDepositTerm, this.maxDepositTerm); @@ -166,6 +181,14 @@ public class DepositTermDetail { return this.inMultiplesOfDepositTermType; } + public Integer renewalGracePeriodDays() { + return this.renewalGracePeriodDays; + } + + public void setRenewalGracePeriodDays(final Integer renewalGracePeriodDays) { + this.renewalGracePeriodDays = renewalGracePeriodDays; + } + public boolean isDepositBetweenMinAndMax(LocalDate depositStartDate, LocalDate depositEndDate) { return isEqualOrGreaterThanMin(depositStartDate, depositEndDate) && isEqualOrLessThanMax(depositStartDate, depositEndDate); } @@ -260,7 +283,9 @@ public class DepositTermDetail { final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = SavingsPeriodFrequencyType .fromInt(this.inMultiplesOfDepositTermType()); - return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, - inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + final DepositTermDetail copy = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, + maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); + copy.renewalGracePeriodDays = this.renewalGracePeriodDays; + return copy; } } 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 index 8699d91cb..360a9d6dd 100644 --- 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 @@ -68,4 +68,7 @@ public class FixedDepositEditFields { @Column(name = "deposit_amount") private BigDecimal depositAmount; + + @Column(name = "renewal_grace_period_days") + private Integer renewalGracePeriodDays; } 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 index 9fa9fe453..04534c5ac 100644 --- 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 @@ -942,6 +942,10 @@ public final class ProductPendingEditFieldMapper { f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount")); present.add("depositAmount"); } + if (command.hasParameter("renewalGracePeriodDays")) { + f.setRenewalGracePeriodDays(command.integerValueOfParameterNamed("renewalGracePeriodDays")); + present.add("renewalGracePeriodDays"); + } return present; } @@ -962,6 +966,7 @@ public final class ProductPendingEditFieldMapper { if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount()); if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount()); if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount()); + if (f.getRenewalGracePeriodDays() != null) putNumber(root, submitted, "renewalGracePeriodDays", f.getRenewalGracePeriodDays()); } public static Set populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) { From d2e13cc7b9363ba3c71c7846bd836ea3f1b468aa Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:55:25 +0530 Subject: [PATCH 04/39] feat(fd): dashboard-driven maturity renewal, plus fix stranded interest on Reinvest Principal Only A SINGLE/DOUBLE FD opened with an explicit maturity instruction still auto-processes on its own via the nightly job, exactly as before -- the grace period never delays that. An FD opened with NO instruction at all (the new default -- see the frontend's account-creation changes) is now never touched by the job; it just sits matured until staff renew it manually from the FD Dashboard, backdated to the original maturity date within the product's grace window, or from today with gap-period interest (at the linked account's rate) credited first once it's passed. New endpoints: - POST /fixeddepositaccounts/{id}/renew -- the Dashboard's own quick Reinvest choice (Principal+Interest / Principal Only), for the two-option case. - POST /fixeddepositaccounts/{id}/credit-gap-interest -- a pre-step the frontend's Payment dialog now calls before submitting the existing generic `payment` command, so the Dashboard's Renew action offers every closure type Payment does (Transfer to Savings, both Reinvest options, Partial Reinvest), not just two, while still crediting gap-period interest first. Also fixes a real, pre-existing bug found while building this: REINVEST_PRINCIPAL_ONLY never actually paid its leftover interest anywhere -- handleFDAccountMaturityClosure withdrew only the principal into the new deposit and never referenced its own toSavingsId parameter, silently stranding the interest on an account it also never closed. Now transfers the remainder to the target savings account and actually closes the old account. Co-Authored-By: Claude Sonnet 5 --- .../FixedDepositGapInterestApiResource.java | 66 +++++++ .../api/FixedDepositRenewalApiResource.java | 78 ++++++++ .../DepositAccountDomainServiceJpa.java | 28 +++ ...WritePlatformServiceJpaRepositoryImpl.java | 185 ++++++++++++++++++ .../savings/starter/SavingsConfiguration.java | 4 +- .../tenant/final-changelog-tenant.xml | 4 + ...l_grace_period_to_deposit_account_term.xml | 30 +++ .../DepositAccountWritePlatformService.java | 22 +++ 8 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java new file mode 100644 index 000000000..dd5191542 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.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.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic + * `payment` command (the same one the account's own Account Actions menu already uses) -- so the + * Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options, + * Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the + * gap-period interest credited first when the product's renewal grace window has passed. A no-op + * (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all -- + * see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.") +@RequiredArgsConstructor +public class FixedDepositGapInterestApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String creditGapInterest(@PathParam("accountId") final Long accountId) { + // Posts a real interest transaction when past grace, so it needs write/update, not just read. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java new file mode 100644 index 000000000..419e6247f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.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.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the + * original maturity date within the product's renewal grace window (0 days if none is configured), or + * from today with a gap-period interest credit (at the linked Savings account's rate) once the grace + * window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is + * made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored + * maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not + * consulted. All the actual renewal and gap-interest logic lives in + * DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same + * collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renew") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.") +@RequiredArgsConstructor +public class FixedDepositRenewalApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String renew(@PathParam("accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + // A renewal moves money (or credits gap-period interest), so it needs the write/update + // permission on this resource, not just read -- unlike the dashboard's own read-only endpoint. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject() + : JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject(); + final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull()) + ? body.get("reinvestType").getAsInt() + : null; + final Map result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 62d049e95..a884d9f19 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -355,6 +355,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi // an in-place renewal has nowhere to send. Left on the original flow rather than guessing. final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal reInvestAmount = account.getDepositAmount(); + // Whatever is left after the principal moves into the new deposit is interest the customer + // earned and has to actually be paid out somewhere -- it must not just sit on this account, + // which is about to be closed. Validated before anything below runs, so a missing target + // fails cleanly with nothing done rather than leaving a half-completed reinvestment. + final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount); + if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { + throw new GeneralPlatformDomainRuleException( + "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", + "Reinvest Principal Only requires a Savings account to receive the leftover interest."); + } ExternalId externalId = this.externalIdFactory.create(); FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); @@ -384,6 +394,24 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); + // Pay out whatever didn't move into the new deposit, then actually close this account -- + // previously this branch left the leftover interest stranded on an account that was never + // closed, silently, because nothing past this point ever referenced toSavingsId at all. + if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO leftoverTransferDTO = new AccountTransferDTO(closedDate, leftoverInterest, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, + "Reinvest Principal Only -- interest paid out", null, fmt, null, null, null, null, null, + AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, + isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } + account.updateClosedStatus(); + account.updateOnAccountClosureStatus(onClosureType); + } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 788177150..4484134c7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -28,6 +28,7 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -86,6 +87,7 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; @@ -99,6 +101,7 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail; import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; +import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct; import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; @@ -112,6 +115,7 @@ import org.apache.fineract.portfolio.savings.exception.DepositAccountTransaction import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -142,6 +146,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo private final WorkingDaysRepositoryWrapper workingDaysRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -1531,11 +1536,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); // handle maturity instructions + // An account opened WITH an explicit maturity instruction (Transfer to Savings or either + // Reinvest option) auto-processes here, tonight, exactly as every FD always has -- the + // renewal grace period never delays this, no matter what the product has configured. Grace + // only matters for an account that was opened with NO instruction at all: that one is never + // touched here (isReinvestOnClosure()/isTransferToSavingsOnClosure() are both false for a + // null closure type), so it just sits matured until a human renews it from the FD Dashboard + // -- see renewMaturedFixedDeposit(), which is where the grace window is actually read. if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); Map changes = new HashMap<>(); final AppUser user = context.authenticatedUser(); Long toSavingsId = fdAccount.getTransferToSavingsAccountId(); + // Reinvest Principal Only needs a target for the leftover interest (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- the account-opening + // form only collects transferToSavingsId when Transfer to Savings itself was chosen, so + // an account opened straight into Reinvest Principal Only can reach here with none set. + // Falls back to the FD's own Deposit Account Link rather than failing the whole job run. + if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { + toSavingsId = fdAccount.getLinkedAccountId(); + } this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); @@ -1578,6 +1598,171 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds); } + @Transactional + @Override + public Map renewMaturedFixedDeposit(final Long fdAccountId, final Integer reinvestType) { + final AppUser user = context.authenticatedUser(); + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet -- nothing to renew."); + } + // The account's own stored maturity instruction is no longer authoritative -- new SINGLE/DOUBLE + // FDs are opened without one at all (see create-fixed-deposit-account.component.ts), because + // what happens at maturity is now decided here, by staff, at the moment of renewal. + final DepositAccountOnClosureType chosenClosureType = DepositAccountOnClosureType.fromInt(reinvestType); + if (!chosenClosureType.isReinvestPrincipalAndInterest() && !chosenClosureType.isReinvestPrincipal()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.reinvest.type.required", + "Choose Reinvest Principal + Interest or Reinvest Principal Only."); + } + // Only Principal pays the leftover interest out to the FD's own linked Savings account -- the + // account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this + // should always be present, but fail clearly rather than silently stranding the interest if not. + final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId(); + if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, " + + "so the leftover interest has somewhere to go."); + } + + // Renewal grace period only shrinks the backdate window below -- it is no longer a precondition + // for whether Renew is allowed at all. A product that never configured one behaves as a 0-day + // window: renewing on the maturity date itself backdates, any later renewal pays gap interest. + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { + renewalGracePeriodDays = 0; + } + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate graceDeadline = maturityDate.plusDays(renewalGracePeriodDays); + final boolean withinGrace = !today.isAfter(graceDeadline); + + final Map result = new HashMap<>(); + BigDecimal gapInterestAmount = null; + + if (!withinGrace) { + // Late: the backdated renewal is no longer offered. The gap between the original maturity + // date and today is compensated at the linked Savings account's own product rate (simple + // interest for that stretch) before the FD renews from today, so the new term's principal + // already includes it. + gapInterestAmount = creditGapInterest(fdAccount, maturityDate, today); + } + + final LocalDate renewalDate = withinGrace ? maturityDate : today; + final Map changes = new HashMap<>(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, renewalDate, reinvestType, + chosenClosureType.isReinvestPrincipal() ? linkedSavingsAccountId : null, + withinGrace ? "Manual renewal within grace period" : "Manual renewal after grace period, gap interest credited at linked savings rate", + changes); + markWithdrawalsAsAlreadyPosted(fdAccount, existingTransactionIds); + this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); + postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + // REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId); + // REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that + // actually carries the new term's maturity date/amount either way. + FixedDepositAccount renewedAccount = fdAccount; + if (changes.get("reinvestedDepositId") != null) { + renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"), + DepositAccountType.FIXED_DEPOSIT); + } + result.put("renewedAccountId", renewedAccount.getId()); + result.put("maturityDate", renewedAccount.maturityDate() != null ? renewedAccount.maturityDate().toString() : null); + result.put("maturityAmount", renewedAccount.maturityAmount()); + result.put("backdated", withinGrace); + if (gapInterestAmount != null) { + result.put("gapInterestAmount", gapInterestAmount); + } + return result; + } + + @Transactional + @Override + public Map creditGapInterestIfPastGrace(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet."); + } + + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { + renewalGracePeriodDays = 0; + } + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + + final Map result = new HashMap<>(); + result.put("withinGrace", withinGrace); + result.put("gapInterestAmount", withinGrace ? BigDecimal.ZERO : creditGapInterest(fdAccount, maturityDate, today)); + return result; + } + + /** + * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its + * linked Savings/Current account's product -- credited onto the FD itself as a genuine + * INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the + * cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD + * Dashboard's generic Payment command) reads the account's balance. Shared by + * {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's + * Renew action now calls the latter as a pre-step before submitting the same generic `payment` + * command the account's own Account Actions menu already uses, so every closure type (Transfer to + * Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the + * two this method's sibling endpoint originally supported. + */ + private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { + final Long linkedAccountId = fdAccount.getLinkedAccountId(); + if (linkedAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it " + + "before renewing late, so the gap-period interest has a rate to use."); + } + final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject(""" + SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, BigDecimal.class, linkedAccountId); + + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 + : fdAccount.getInterestCalculationDaysInYearType(); + final MathContext mc = MoneyHelper.getMathContext(); + final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc) + .multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc) + .setScale(2, java.math.RoundingMode.HALF_UP); + + if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(), + today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true); + fdAccount.addTransaction(gapInterestTxn); + fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions()); + this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); + postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + } + return gapInterestAmount; + } + private void updateExistingTransactionsDetails(SavingsAccount account, Set existingTransactionIds, Set existingReversedTransactionIds) { existingTransactionIds.addAll(account.findExistingTransactionIds()); 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 1eef688a9..976d2e6ce 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 @@ -297,7 +297,7 @@ public class SavingsConfiguration { ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository, WorkingDaysRepositoryWrapper workingDaysRepository, DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, - DenominationBreakdownWriter denominationBreakdownWriter + DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate ) { return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper, @@ -307,7 +307,7 @@ public class SavingsConfiguration { chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService, accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository, configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository, - denominationBreakdownWriter); + denominationBreakdownWriter, jdbcTemplate); } @Bean diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 545b6450b..9813f48f7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -116,4 +116,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml new file mode 100644 index 000000000..ac4917332 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index 979941229..11b836c37 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -92,5 +92,27 @@ public interface DepositAccountWritePlatformService { void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType); + /** + * Manually renews a matured FD from the FD Dashboard -- backdated to the original maturity date if + * still within the product's renewal grace window (0 days if none is configured), or from today + * with a gap-period interest credit (at the linked Savings account's product rate) if not. + * {@code reinvestType} is the staff's choice made at renewal time (300 = Reinvest Principal and + * Interest, 400 = Reinvest Principal Only -- the rest of the balance is paid to the FD's linked + * Savings account); the account's own stored maturity instruction, if any, is not consulted. + * Returns a small result map: renewedAccountId, maturityDate, maturityAmount, backdated (boolean), + * gapInterestAmount (BigDecimal, present only when backdated is false). + */ + Map renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType); + + /** + * Credits gap-period interest (simple interest, at the linked Savings/Current account's product + * rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning + * zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the + * generic `payment` command (the same one the account's own Account Actions menu uses), so every + * closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit} + * itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). + */ + Map creditGapInterestIfPastGrace(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); } From 9beae3df2890a441c4777a7fbd9aec494a35f7cc Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 24 Sep 2026 17:22:39 +0530 Subject: [PATCH 05/39] Implement organization maker-checker workflows --- .../service/CommandWrapperBuilder.java | 79 ++++++++++ .../monetary/api/CurrenciesApiResource.java | 23 +-- .../handler/UpdateCurrencyCommandHandler.java | 63 ++++++++ .../api/PaymentTypeApiResource.java | 48 +++--- .../PaymentTypeCreateCommandHandler.java | 51 ++++-- .../PaymentTypeDeleteCommandHandler.java | 42 +++-- .../PaymentTypeUpdateCommandHandler.java | 52 +++++-- .../core/config/SecurityConfig.java | 49 +++++- .../api/WorkingDaysApiResource.java | 27 ++-- .../UpdateWorkingDaysCommandHandler.java | 50 +++--- .../portfolio/fund/api/FundsApiResource.java | 13 ++ .../fund/api/FundsApiResourceSwagger.java | 9 ++ .../handler/DeleteFundCommandHandler.java | 47 ++++++ .../service/FundWritePlatformService.java | 2 + ...WritePlatformServiceJpaRepositoryImpl.java | 41 +++++ .../fund/starter/FundConfiguration.java | 4 +- .../api/BranchProfileApiResource.java | 84 +++------- .../api/InstitutionProfileApiResource.java | 120 ++------------ .../UpdateBranchProfileCommandHandler.java | 62 ++++++++ ...pdateInstitutionProfileCommandHandler.java | 88 +++++++++++ .../BranchProfileWritePlatformService.java | 29 ++++ ...BranchProfileWritePlatformServiceImpl.java | 103 ++++++++++++ ...nstitutionProfileWritePlatformService.java | 36 +++++ ...tutionProfileWritePlatformServiceImpl.java | 134 ++++++++++++++++ .../sponsordd/api/SponsorBankApiResource.java | 112 +++---------- .../sponsordd/api/SponsorDDApiResource.java | 36 +++-- .../CreateSponsorBankCommandHandler.java | 62 ++++++++ .../UpdateSponsorBankCommandHandler.java | 63 ++++++++ ...pdateSponsorDdGlMappingCommandHandler.java | 74 +++++++++ .../SponsorBankWritePlatformService.java | 29 ++++ .../SponsorBankWritePlatformServiceImpl.java | 147 ++++++++++++++++++ .../PermissionReadPlatformServiceImpl.java | 21 ++- .../UserPendingReadPlatformServiceImpl.java | 102 +++++++++++- .../UserPendingWritePlatformServiceImpl.java | 2 +- .../src/main/resources/application.properties | 4 +- .../tenant/final-changelog-tenant.xml | 2 + ...1_add_sponsor_dd_gl_mapping_permission.xml | 45 ++++++ ...572_add_sponsor_bank_update_permission.xml | 44 ++++++ 38 files changed, 1589 insertions(+), 410 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/organisation/monetary/handler/UpdateCurrencyCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/handler/DeleteFundCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateBranchProfileCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateInstitutionProfileCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/CreateSponsorBankCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorBankCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorDdGlMappingCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_add_sponsor_dd_gl_mapping_permission.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_sponsor_bank_update_permission.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index af0fb4ddd..509a6f71e 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -756,6 +756,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder deleteFund(final Long fundId) { + this.actionName = "DELETE"; + this.entityName = "FUND"; + this.entityId = fundId; + this.href = "/funds/" + fundId; + return this; + } + public CommandWrapperBuilder createReport() { this.actionName = "CREATE"; this.entityName = "REPORT"; @@ -884,6 +892,70 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder createPaymentType() { + this.actionName = "CREATE"; + this.entityName = "PAYMENTTYPE"; + this.entityId = null; + this.href = "/paymenttypes/template"; + return this; + } + + public CommandWrapperBuilder updatePaymentType(final Long paymentTypeId) { + this.actionName = "UPDATE"; + this.entityName = "PAYMENTTYPE"; + this.entityId = paymentTypeId; + this.href = "/paymenttypes/" + paymentTypeId; + return this; + } + + public CommandWrapperBuilder deletePaymentType(final Long paymentTypeId) { + this.actionName = "DELETE"; + this.entityName = "PAYMENTTYPE"; + this.entityId = paymentTypeId; + this.href = "/paymenttypes/" + paymentTypeId; + return this; + } + + public CommandWrapperBuilder createSponsorBank() { + this.actionName = "CREATE"; + this.entityName = "SPONSORBANK"; + this.entityId = null; + this.href = "/sponsorbanks"; + return this; + } + + public CommandWrapperBuilder updateSponsorBank(final Long sponsorBankId) { + this.actionName = "UPDATE"; + this.entityName = "SPONSORBANK"; + this.entityId = sponsorBankId; + this.href = "/sponsorbanks/" + sponsorBankId; + return this; + } + + public CommandWrapperBuilder updateSponsorDdGlMapping() { + this.actionName = "UPDATE"; + this.entityName = "SPONSORDDGLMAPPING"; + this.entityId = null; + this.href = "/sponsordd/gl-mapping"; + return this; + } + + public CommandWrapperBuilder updateInstitutionProfile() { + this.actionName = "UPDATE"; + this.entityName = "INSTITUTIONPROFILE"; + this.entityId = null; + this.href = "/institutionprofile"; + return this; + } + + public CommandWrapperBuilder updateBranchProfile(final Long officeId) { + this.actionName = "UPDATE"; + this.entityName = "BRANCHPROFILE"; + this.entityId = officeId; + this.href = "/branchprofile/" + officeId; + return this; + } + public CommandWrapperBuilder createLoanProduct() { this.actionName = "CREATE"; this.entityName = "LOANPRODUCT"; @@ -3390,6 +3462,13 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder updateCurrency() { + this.actionName = "UPDATE"; + this.entityName = "CURRENCY"; + this.href = "/currencies"; + return this; + } + public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) { this.actionName = "UPDATE"; this.entityName = "EXTERNALSERVICES"; diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java index ff3ac4e61..953541927 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java @@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import java.util.function.Supplier; import lombok.RequiredArgsConstructor; -import org.apache.fineract.command.core.CommandPipeline; -import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData; import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest; -import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse; import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService; import org.springframework.stereotype.Component; @@ -44,7 +45,8 @@ import org.springframework.stereotype.Component; public class CurrenciesApiResource { private final OrganisationCurrencyReadPlatformService readPlatformService; - private final CommandPipeline commandPipeline; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -65,13 +67,12 @@ public class CurrenciesApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.") - public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) { - final var command = new CurrencyUpdateCommand(); + public String updateCurrencies(@Valid final CurrencyUpdateRequest request) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrency() + .withJson(toApiJsonSerializer.serialize(request)).build(); - command.setPayload(request); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return toApiJsonSerializer.serialize(result); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/handler/UpdateCurrencyCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/handler/UpdateCurrencyCommandHandler.java new file mode 100644 index 000000000..d4aa3a9e1 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/handler/UpdateCurrencyCommandHandler.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.organisation.monetary.handler; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest; +import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse; +import org.apache.fineract.organisation.monetary.service.CurrencyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "CURRENCY", action = "UPDATE") +public class UpdateCurrencyCommandHandler implements NewCommandSourceHandler { + + private final CurrencyWritePlatformService writePlatformService; + + @Autowired + public UpdateCurrencyCommandHandler(final CurrencyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final String[] currencyCodes = command.arrayValueOfParameterNamed("currencies"); + final List currencies = currencyCodes == null ? List.of() : Arrays.asList(currencyCodes); + + final CurrencyUpdateRequest request = CurrencyUpdateRequest.builder().currencies(currencies).build(); + + final CurrencyUpdateResponse response = this.writePlatformService.updateAllowedCurrencies(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .with(Map.of("currencies", response.getCurrencies())) // + .build(); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java index 4be438b08..6ca135657 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java @@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import java.util.List; -import java.util.function.Supplier; import lombok.RequiredArgsConstructor; -import org.apache.fineract.command.core.CommandPipeline; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; -import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeCreateCommand; -import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeDeleteCommand; -import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeUpdateCommand; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.springframework.stereotype.Component; @@ -55,7 +50,7 @@ import org.springframework.stereotype.Component; public class PaymentTypeApiResource { private final PaymentTypeReadService readPlatformService; - private final CommandPipeline commandPipeline; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final DefaultToApiJsonSerializer jsonSerializer; @GET @@ -79,14 +74,13 @@ public class PaymentTypeApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Create a Payment Type", description = "Creates a new Payment type") - public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) { - final var command = new PaymentTypeCreateCommand(); + public String createPaymentType(final PaymentTypeCreateRequest request) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().createPaymentType() + .withJson(jsonSerializer.serialize(request)).build(); - command.setPayload(request); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return jsonSerializer.serialize(result); } @PUT @@ -94,18 +88,16 @@ public class PaymentTypeApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update a Payment Type", description = "Updates a Payment Type") - public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, - final PaymentTypeUpdateRequest request) { + public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) { request.setId(paymentTypeId); - final var command = new PaymentTypeUpdateCommand(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updatePaymentType(paymentTypeId) + .withJson(jsonSerializer.serialize(request)).build(); - command.setPayload(request); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return jsonSerializer.serialize(result); } @DELETE @@ -113,14 +105,12 @@ public class PaymentTypeApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Delete a Payment Type", description = "Deletes payment type") - public PaymentTypeDeleteResponse deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) { + public String deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) { - final var command = new PaymentTypeDeleteCommand(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().deletePaymentType(paymentTypeId).build(); - command.setPayload(PaymentTypeDeleteRequest.builder().id(paymentTypeId).build()); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return jsonSerializer.serialize(result); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java index e7604f6e8..292c6bed3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java @@ -18,33 +18,50 @@ */ package org.apache.fineract.portfolio.paymenttype.handler; -import io.github.resilience4j.retry.annotation.Retry; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentTypeCreateCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "PAYMENTTYPE", action = "CREATE") +public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler { private final PaymentTypeWriteService paymentTypeWriteService; - @Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback") - @Override - @Transactional - public PaymentTypeCreateResponse handle(Command command) { - return paymentTypeWriteService.createPaymentType(command.getPayload()); + @Autowired + public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) { + this.paymentTypeWriteService = paymentTypeWriteService; } + @Transactional @Override - public PaymentTypeCreateResponse fallback(Command command, Throwable t) { - return CommandHandler.super.fallback(command, t); + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined"); + + final PaymentTypeCreateRequest request = PaymentTypeCreateRequest.builder() + .name(command.stringValueOfParameterNamed("name")) + .description(command.stringValueOfParameterNamed("description")) + .isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment")) + .position(command.longValueOfParameterNamed("position")) + .codeName(command.stringValueOfParameterNamed("codeName")) + .isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE) + .build(); + + final PaymentTypeCreateResponse response = this.paymentTypeWriteService.createPaymentType(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(response.getResourceId()) // + .build(); } + } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java index 9c1e9d050..8e8cdc4c6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java @@ -18,33 +18,41 @@ */ package org.apache.fineract.portfolio.paymenttype.handler; -import io.github.resilience4j.retry.annotation.Retry; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentTypeDeleteCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "PAYMENTTYPE", action = "DELETE") +public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler { private final PaymentTypeWriteService paymentTypeWriteService; - @Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback") - @Override - @Transactional - public PaymentTypeDeleteResponse handle(Command command) { - return paymentTypeWriteService.deletePaymentType(command.getPayload()); + @Autowired + public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) { + this.paymentTypeWriteService = paymentTypeWriteService; } + @Transactional @Override - public PaymentTypeDeleteResponse fallback(Command command, Throwable t) { - return CommandHandler.super.fallback(command, t); + public CommandProcessingResult processCommand(final JsonCommand command) { + + final PaymentTypeDeleteRequest request = PaymentTypeDeleteRequest.builder().id(command.entityId()).build(); + + final PaymentTypeDeleteResponse response = this.paymentTypeWriteService.deletePaymentType(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(response.getResourceId()) // + .build(); } + } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java index 41914c9e5..cc3f50854 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java @@ -18,33 +18,51 @@ */ package org.apache.fineract.portfolio.paymenttype.handler; -import io.github.resilience4j.retry.annotation.Retry; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentTypeUpdateCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "PAYMENTTYPE", action = "UPDATE") +public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler { private final PaymentTypeWriteService paymentTypeWriteService; - @Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback") - @Override - @Transactional - public PaymentTypeUpdateResponse handle(Command command) { - return paymentTypeWriteService.updatePaymentType(command.getPayload()); + @Autowired + public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) { + this.paymentTypeWriteService = paymentTypeWriteService; } + @Transactional @Override - public PaymentTypeUpdateResponse fallback(Command command, Throwable t) { - return CommandHandler.super.fallback(command, t); + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined"); + + final PaymentTypeUpdateRequest request = PaymentTypeUpdateRequest.builder() + .id(command.entityId()) + .name(command.stringValueOfParameterNamed("name")) + .description(command.stringValueOfParameterNamed("description")) + .isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment")) + .position(command.longValueOfParameterNamed("position")) + .codeName(command.stringValueOfParameterNamed("codeName")) + .isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE) + .build(); + + final PaymentTypeUpdateResponse response = this.paymentTypeWriteService.updatePaymentType(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(response.getResourceId()) // + .build(); } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index b918422a1..4e739e895 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -222,7 +222,10 @@ public class SecurityConfig { // currency .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY") - .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currencies")) + // the actual currency-update endpoint is PUT (CurrenciesApiResource has no @POST method) - + // this matcher previously targeted POST, which meant PUT fell through to the generic + // fullyAuthenticated() catch-all with no permission check at all. + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currencies")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY") // notes: read .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes")) @@ -355,11 +358,18 @@ public class SecurityConfig { .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT") // payment type + // NOTE: POST previously required "UPDATE_PAYMENTTYPE" (wrong authority for create), with a + // second, unreachable matcher for the same POST pattern requiring "DELETE_PAYMENTTYPE" - and + // no PUT/DELETE matcher existed at all, so those requests fell through to the generic + // fullyAuthenticated() catch-all below with no permission check. Corrected to one matcher + // per method, each requiring its own correct permission code. .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE") .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_PAYMENTTYPE") + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/paymenttypes/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE") - .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes")) + .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/paymenttypes/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE") // mix: taxonomy .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*")) @@ -390,12 +400,41 @@ public class SecurityConfig { .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT") // working days + // NOTE: previously "READ_WORKING_DAYS"/"UPDATE_WORKING_DAYS" (with an underscore between + // WORKING and DAYS), which did not match the actual seeded permission codes + // (READ_WORKINGDAYS / UPDATE_WORKINGDAYS) - corrected to the real codes. .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays")) - .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS") + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS") .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template")) - .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS") + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS") .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays")) - .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKING_DAYS") + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKINGDAYS") + // sponsor banks + // NOTE: no matcher previously existed at all for this path - POST/PUT both fell + // through to the generic fullyAuthenticated() catch-all below with no permission + // check whatsoever. + .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsorbanks")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SPONSORBANK") + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/sponsorbanks/*")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORBANK") + // sponsor DD GST/GL settings + // NOTE: no matcher previously existed at all for this path either. gl-mapping is a + // genuine upsert (one call both creates and updates the single settings row), so it is + // covered by one UPDATE_SPONSORDDGLMAPPING matcher rather than a separate CREATE one. + .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsordd/gl-mapping")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORDDGLMAPPING") + // bank profile (institution profile) + // NOTE: no matcher previously existed at all for this path - the PUT relied solely on + // the in-resource validateHasPermissionTo() check, which this change replaces with the + // command-source pipeline. Added here for the same defense-in-depth already applied to + // Currency/Payment Type/Working Days. + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/institutionprofile")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_INSTITUTIONPROFILE") + // branch profile + // NOTE: no matcher previously existed at all for this path either, same rationale as + // institution profile above. + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/branchprofile/*")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_BRANCHPROFILE") .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated() .requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated() diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java index d58dd0bfa..7134ee9ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java @@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import java.time.Instant; -import java.util.function.Supplier; import lombok.RequiredArgsConstructor; -import org.apache.fineract.command.core.CommandPipeline; -import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.organisation.workingdays.data.WorkingDaysData; import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest; import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator; -import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse; import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService; import org.springframework.stereotype.Component; @@ -50,7 +50,8 @@ public class WorkingDaysApiResource { private final WorkingDaysReadPlatformService workingDaysReadPlatformService; private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator; - private final CommandPipeline commandPipeline; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -65,17 +66,13 @@ public class WorkingDaysApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update a Working Day", description = "Mandatory Fields\n" + "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale") - public WorkingDaysUpdateResponse update(@Valid WorkingDaysUpdateRequest request) { + public String update(@Valid final WorkingDaysUpdateRequest request) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateWorkingDays() + .withJson(toApiJsonSerializer.serialize(request)).build(); - final var command = new WorkingDaysUpdateCommand(); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - command.setCommandId(System.currentTimeMillis()); - command.setCreatedAt(Instant.now()); - command.setPayload(request); - - final Supplier response = commandPipeline.send(command); - - return response.get(); + return toApiJsonSerializer.serialize(result); } @GET diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java index 2528d818f..821516099 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java @@ -18,38 +18,46 @@ */ package org.apache.fineract.organisation.workingdays.handler; -import java.util.Collections; import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest; -import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse; import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class UpdateWorkingDaysCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "WORKINGDAYS", action = "UPDATE") +public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler { private final WorkingDaysWritePlatformService workingDaysWritePlatformService; + @Autowired + public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) { + this.workingDaysWritePlatformService = workingDaysWritePlatformService; + } + @Transactional @Override - public WorkingDaysUpdateResponse handle(Command command) { - WorkingDaysUpdateRequest request = command.getPayload(); - Map changes = this.workingDaysWritePlatformService.updateWorkingDays(request); - if (changes == null) { - changes = Collections.emptyMap(); - } + public CommandProcessingResult processCommand(final JsonCommand command) { - return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes) - .recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType()) - .extendTermForDailyRepayments(request.getExtendTermForDailyRepayments()) - .extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build(); + final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder() + .recurrence(command.stringValueOfParameterNamed("recurrence")) + .repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType")) + .extendTermForDailyRepayments(command.booleanObjectValueOfParameterNamed("extendTermForDailyRepayments")) + .extendTermForRepaymentsOnHolidays(command.booleanObjectValueOfParameterNamed("extendTermForRepaymentsOnHolidays")) + .build(); + + final Map changes = this.workingDaysWritePlatformService.updateWorkingDays(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .with(changes) // + .build(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java index 94d7dd76e..3b6706fe2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java @@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; @@ -114,4 +115,16 @@ public class FundsApiResource { final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); return toApiJsonSerializer.serialize(result); } + + @DELETE + @Path("{fundId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Delete a Fund", description = "Deletes a fund outright. Fails if the fund is still referenced by any loan or loan product.") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = FundsApiResourceSwagger.DeleteFundsFundIdResponse.class))) + public String deleteFund(@PathParam("fundId") @Parameter(description = "fundId") final Long fundId) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().deleteFund(fundId).build(); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + return toApiJsonSerializer.serialize(result); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java index 746a65c35..0756f2c92 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java @@ -55,4 +55,13 @@ final class FundsApiResourceSwagger { public Long resourceId; public PutFundsFundIdRequest changes; } + + @Schema(description = "DeleteFundsFundIdResponse") + public static final class DeleteFundsFundIdResponse { + + private DeleteFundsFundIdResponse() {} + + @Schema(example = "1") + public Long resourceId; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/handler/DeleteFundCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/handler/DeleteFundCommandHandler.java new file mode 100644 index 000000000..34765a911 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/handler/DeleteFundCommandHandler.java @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.fund.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.fund.service.FundWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "FUND", action = "DELETE") +public class DeleteFundCommandHandler implements NewCommandSourceHandler { + + private final FundWritePlatformService writePlatformService; + + @Autowired + public DeleteFundCommandHandler(final FundWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + return this.writePlatformService.deleteFund(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java index 3eeddd82b..3d2d8a5be 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java @@ -26,4 +26,6 @@ public interface FundWritePlatformService { CommandProcessingResult createFund(JsonCommand command); CommandProcessingResult updateFund(Long fundId, JsonCommand command); + + CommandProcessingResult deleteFund(Long fundId, JsonCommand command); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java index 550e8aaa9..b6e7232db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java @@ -35,6 +35,7 @@ import org.apache.fineract.portfolio.fund.exception.FundNotFoundException; import org.apache.fineract.portfolio.fund.serialization.FundCommandFromApiJsonDeserializer; import org.springframework.cache.annotation.CacheEvict; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.transaction.annotation.Transactional; @@ -45,6 +46,7 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf private final PlatformSecurityContext context; private final FundCommandFromApiJsonDeserializer fromApiJsonDeserializer; private final FundRepository fundRepository; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -99,6 +101,45 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf } } + @Transactional + @Override + @CacheEvict(value = "funds", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat('fn')") + public CommandProcessingResult deleteFund(final Long fundId, final JsonCommand command) { + + this.context.authenticatedUser(); + + final Fund fund = this.fundRepository.findById(fundId).orElseThrow(() -> new FundNotFoundException(fundId)); + + requireNoneReference(fundId, "m_loan", "fund_id", "loan", "loans", + "Reassign or remove the loans using this fund before deleting it."); + requireNoneReference(fundId, "m_product_loan", "fund_id", "loan product", "loan products", + "Remove this fund from the loan products using it before deleting it."); + + try { + this.fundRepository.delete(fund); + this.fundRepository.flush(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references", + "This fund cannot be deleted: other records still reference it."); + } catch (final PersistenceException dve) { + throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references", + "This fund cannot be deleted: other records still reference it."); + } + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(fundId).build(); + } + + private void requireNoneReference(final Long fundId, final String table, final String column, final String singular, + final String plural, final String remedy) { + final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class, + fundId); + if (count != null && count > 0) { + final String noun = count == 1 ? singular : plural; + throw new PlatformDataIntegrityException("error.msg.fund.cannot.delete.still.referenced", + "This fund cannot be deleted: it is still referenced by " + count + " " + noun + ". " + remedy, "fundId", fundId); + } + } + /* * Guaranteed to throw an exception no matter what the data integrity issue is. */ diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java index dfc9dae3a..450e0fcf9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java @@ -42,7 +42,7 @@ public class FundConfiguration { @Bean @ConditionalOnMissingBean(FundWritePlatformService.class) public FundWritePlatformService fundWritePlatformService(PlatformSecurityContext context, - FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository) { - return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository); + FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository, JdbcTemplate jdbcTemplate) { + return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository, jdbcTemplate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java index 55f7dd7e1..7c4ee8fcb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.orgsetup.api; import com.google.gson.Gson; +import com.google.gson.JsonObject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.PUT; @@ -27,17 +28,18 @@ import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; -import java.time.Instant; -import java.sql.Timestamp; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -57,6 +59,7 @@ public class BranchProfileApiResource { private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final Gson gson = new Gson(); @GET @@ -84,68 +87,23 @@ public class BranchProfileApiResource { @PUT @Path("{officeId}") @Consumes({ MediaType.APPLICATION_JSON }) - public String update(@PathParam("officeId") final Long officeId, final String body) { - this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE"); - - final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", - Integer.class, officeId); - if (officeExists == null || officeExists == 0) { - throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", - "Office " + officeId + " was not found."); - } - + public CommandProcessingResult update(@PathParam("officeId") final Long officeId, final String body) { + // The write itself (office-exists check, branch-code uniqueness check, m_office_profile + // upsert) now lives in BranchProfileWritePlatformService, invoked via + // UpdateBranchProfileCommandHandler so it goes through the generic Maker-Checker gate. The + // permission check that used to happen here explicitly is now enforced by SecurityConfig's + // matcher plus the command-source pipeline itself, the same as every other endpoint already + // migrated to this pipeline. A "locale" key is added if the body does not already carry one - + // JsonCommand's BigDecimal extractor (for latitude/longitude) requires it, and the frontend's + // payload does not currently send one. final var root = this.fromJsonHelper.parse(body); - final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root); - final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root); - final String address = this.fromJsonHelper.extractStringNamed("address", root); - final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); - final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); - final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); - final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root); - final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root); - final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root); - final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root); - final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root); - final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root); - final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root); - - if (branchCode != null && !branchCode.isBlank()) { - final Integer dupe = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class, - branchCode, officeId); - if (dupe != null && dupe > 0) { - throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate", - "Branch code '" + branchCode + "' is already used by another branch."); - } + final JsonObject json = root.getAsJsonObject(); + if (!json.has("locale")) { + json.addProperty("locale", "en"); } - - final AppUser currentUser = this.context.authenticatedUser(); - final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?", - Integer.class, officeId); - if (existing != null && existing > 0) { - jdbcTemplate.update(""" - UPDATE m_office_profile - SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?, - working_hours_start = ?, working_hours_end = ?, working_days = ?, - ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, - updated_by_username = ?, updated_at = ? - WHERE office_id = ? - """, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, - workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), - Timestamp.from(Instant.now()), officeId); - } else { - jdbcTemplate.update(""" - INSERT INTO m_office_profile - (office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email, - working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, - latitude, longitude, updated_by_username, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, - workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), - Timestamp.from(Instant.now())); - } - - return list(officeId); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateBranchProfile(officeId) + .withJson(this.gson.toJson(json)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } private String baseSql() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java index 7bcf6fc7d..a13e9a84d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java @@ -25,14 +25,16 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import java.sql.Date; import java.sql.Timestamp; import java.time.Instant; -import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.useradministration.domain.AppUser; @@ -60,6 +62,7 @@ public class InstitutionProfileApiResource { private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final Gson gson = new Gson(); @GET @@ -89,100 +92,17 @@ public class InstitutionProfileApiResource { @PUT @Consumes({ MediaType.APPLICATION_JSON }) - public String update(final String body) { - this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE"); - ensureRowExists(); - - final var root = this.fromJsonHelper.parse(body); - final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root); - final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root); - final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root); - final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root); - final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root); - final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root); - final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root); - final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root); - final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root); - final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root); - final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root)); - final String status = this.fromJsonHelper.extractStringNamed("status", root); - final String country = this.fromJsonHelper.extractStringNamed("country", root); - final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root); - final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root)); - final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root); - final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root)); - final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root); - final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root); - final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root); - final String city = this.fromJsonHelper.extractStringNamed("city", root); - final String district = this.fromJsonHelper.extractStringNamed("district", root); - final String state = this.fromJsonHelper.extractStringNamed("state", root); - final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); - final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root) - ? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE; - final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root); - final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root) - ? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE; - final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root); - final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); - final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); - final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root); - final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root); - final String website = this.fromJsonHelper.extractStringNamed("website", root); - final String pan = this.fromJsonHelper.extractStringNamed("pan", root); - final String tan = this.fromJsonHelper.extractStringNamed("tan", root); - final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root); - final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root); - final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root); - final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root); - final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root); - final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root); - final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation", - root); - final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root); - final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root); - - // A status flip is stamped automatically the first time it happens, rather than asking the - // user to also remember to set a date — the previous row's own status is what "the first - // time" is judged against. - final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1", - String.class); - final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus); - final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus); - final LocalDate today = LocalDate.now(); - - final AppUser currentUser = this.context.authenticatedUser(); - jdbcTemplate.update(""" - UPDATE m_institution_profile - SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?, - bank_type = ?, ownership_type = ?, parent_institution_name = ?, - registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?, - regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?, - swift_bic = ?, - address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?, - head_office_same_as_registered = ?, head_office_address = ?, - communication_same_as_registered = ?, communication_address = ?, - contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?, - website = ?, pan = ?, tan = ?, gstin = ?, - statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?, - terms_and_conditions = ?, - authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?, - authorized_person_email = ?, - activation_date = CASE WHEN ? THEN ? ELSE activation_date END, - deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END, - updated_by_username = ?, updated_at = ? - WHERE id = 1 - """, bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType, - parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country, - regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic, - addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress, - communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail, - website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions, - authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail, - turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(), - Timestamp.from(Instant.now())); - - return get(); + public CommandProcessingResult update(final String body) { + // The write itself (the JDBC UPDATE of m_institution_profile) now lives in + // InstitutionProfileWritePlatformService, invoked via UpdateInstitutionProfileCommandHandler so + // it goes through the generic Maker-Checker gate. The permission check that used to happen here + // explicitly is now enforced by SecurityConfig's matcher plus the command-source pipeline + // itself, the same as every other endpoint already migrated to this pipeline (Currency, Working + // Days, Payment Type). `body` is forwarded to the command as-is - it already carries the + // `locale` key the frontend sends, which JsonCommand's BigDecimal/date extractors would + // otherwise require. + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateInstitutionProfile().withJson(body).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } private void ensureRowExists() { @@ -204,14 +124,6 @@ public class InstitutionProfileApiResource { return codes.isEmpty() ? null : codes.get(0); } - private LocalDate parseDate(final String raw) { - return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null; - } - - private Date toSqlDate(final LocalDate d) { - return d == null ? null : Date.valueOf(d); - } - private Map toResult(final Map row) { final Map out = new LinkedHashMap<>(); out.put("bankName", row.get("bank_name")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateBranchProfileCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateBranchProfileCommandHandler.java new file mode 100644 index 000000000..e4b3bb09e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateBranchProfileCommandHandler.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.orgsetup.service.BranchProfileWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "BRANCHPROFILE", action = "UPDATE") +public class UpdateBranchProfileCommandHandler implements NewCommandSourceHandler { + + private final BranchProfileWritePlatformService branchProfileWritePlatformService; + + @Autowired + public UpdateBranchProfileCommandHandler(final BranchProfileWritePlatformService branchProfileWritePlatformService) { + this.branchProfileWritePlatformService = branchProfileWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Long officeId = command.entityId(); + + final CommandProcessingResult result = this.branchProfileWritePlatformService.updateBranchProfile(officeId, + command.stringValueOfParameterNamed("branchCode"), command.stringValueOfParameterNamed("branchType"), + command.stringValueOfParameterNamed("address"), command.stringValueOfParameterNamed("pincode"), + command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"), + command.stringValueOfParameterNamed("workingHoursStart"), + command.stringValueOfParameterNamed("workingHoursEnd"), command.stringValueOfParameterNamed("workingDays"), + command.stringValueOfParameterNamed("ifscCode"), command.stringValueOfParameterNamed("micrCode"), + command.bigDecimalValueOfParameterNamed("latitude"), command.bigDecimalValueOfParameterNamed("longitude")); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateInstitutionProfileCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateInstitutionProfileCommandHandler.java new file mode 100644 index 000000000..2d7270d4f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateInstitutionProfileCommandHandler.java @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.orgsetup.service.InstitutionProfileWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INSTITUTIONPROFILE", action = "UPDATE") +public class UpdateInstitutionProfileCommandHandler implements NewCommandSourceHandler { + + private final InstitutionProfileWritePlatformService institutionProfileWritePlatformService; + + @Autowired + public UpdateInstitutionProfileCommandHandler( + final InstitutionProfileWritePlatformService institutionProfileWritePlatformService) { + this.institutionProfileWritePlatformService = institutionProfileWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Boolean headOfficeSame = command.parameterExists("headOfficeSameAsRegistered") + ? command.booleanObjectValueOfParameterNamed("headOfficeSameAsRegistered") : Boolean.TRUE; + final Boolean communicationSame = command.parameterExists("communicationSameAsRegistered") + ? command.booleanObjectValueOfParameterNamed("communicationSameAsRegistered") : Boolean.TRUE; + + final CommandProcessingResult result = this.institutionProfileWritePlatformService.updateInstitutionProfile( + command.stringValueOfParameterNamed("bankName"), command.stringValueOfParameterNamed("legalName"), + command.stringValueOfParameterNamed("shortName"), command.stringValueOfParameterNamed("bankCode"), + command.stringValueOfParameterNamed("institutionCode"), command.stringValueOfParameterNamed("bankType"), + command.stringValueOfParameterNamed("ownershipType"), + command.stringValueOfParameterNamed("parentInstitutionName"), + command.stringValueOfParameterNamed("registrationNumber"), + command.stringValueOfParameterNamed("licenseNumber"), command.stringValueOfParameterNamed("establishedDate"), + command.stringValueOfParameterNamed("status"), command.stringValueOfParameterNamed("country"), + command.stringValueOfParameterNamed("regulatoryAuthority"), + command.stringValueOfParameterNamed("registrationDate"), + command.stringValueOfParameterNamed("licenseStatus"), + command.stringValueOfParameterNamed("licenseExpiryDate"), command.stringValueOfParameterNamed("swiftBic"), + command.stringValueOfParameterNamed("addressLine1"), command.stringValueOfParameterNamed("addressLine2"), + command.stringValueOfParameterNamed("city"), command.stringValueOfParameterNamed("district"), + command.stringValueOfParameterNamed("state"), command.stringValueOfParameterNamed("pincode"), headOfficeSame, + command.stringValueOfParameterNamed("headOfficeAddress"), communicationSame, + command.stringValueOfParameterNamed("communicationAddress"), + command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"), + command.stringValueOfParameterNamed("customerCareNumber"), + command.stringValueOfParameterNamed("grievanceEmail"), command.stringValueOfParameterNamed("website"), + command.stringValueOfParameterNamed("pan"), command.stringValueOfParameterNamed("tan"), + command.stringValueOfParameterNamed("gstin"), command.stringValueOfParameterNamed("statementHeaderName"), + command.stringValueOfParameterNamed("passbookHeaderName"), + command.stringValueOfParameterNamed("receiptHeaderName"), + command.stringValueOfParameterNamed("termsAndConditions"), + command.stringValueOfParameterNamed("authorizedPersonName"), + command.stringValueOfParameterNamed("authorizedPersonDesignation"), + command.stringValueOfParameterNamed("authorizedPersonContact"), + command.stringValueOfParameterNamed("authorizedPersonEmail")); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformService.java new file mode 100644 index 000000000..f80ff95a3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformService.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.orgsetup.service; + +import java.math.BigDecimal; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface BranchProfileWritePlatformService { + + CommandProcessingResult updateBranchProfile(Long officeId, String branchCode, String branchType, String address, + String pincode, String contactPhone, String contactEmail, String workingHoursStart, String workingHoursEnd, + String workingDays, String ifscCode, String micrCode, BigDecimal latitude, BigDecimal longitude); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformServiceImpl.java new file mode 100644 index 000000000..386b9b61a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformServiceImpl.java @@ -0,0 +1,103 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.service; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Moved verbatim out of {@code BranchProfileApiResource.update()} so the write can be invoked from a + * {@code NewCommandSourceHandler} for Maker-Checker support. The resource's own {@code list()} (read) + * method is untouched. + */ +@Service +public class BranchProfileWritePlatformServiceImpl implements BranchProfileWritePlatformService { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Autowired + public BranchProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) { + this.jdbcTemplate = jdbcTemplate; + this.context = context; + } + + @Transactional + @Override + public CommandProcessingResult updateBranchProfile(final Long officeId, final String branchCode, final String branchType, + final String address, final String pincode, final String contactPhone, final String contactEmail, + final String workingHoursStart, final String workingHoursEnd, final String workingDays, final String ifscCode, + final String micrCode, final BigDecimal latitude, final BigDecimal longitude) { + + final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", Integer.class, + officeId); + if (officeExists == null || officeExists == 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", + "Office " + officeId + " was not found."); + } + + if (branchCode != null && !branchCode.isBlank()) { + final Integer dupe = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class, + branchCode, officeId); + if (dupe != null && dupe > 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate", + "Branch code '" + branchCode + "' is already used by another branch."); + } + } + + final AppUser currentUser = this.context.authenticatedUser(); + final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?", + Integer.class, officeId); + if (existing != null && existing > 0) { + jdbcTemplate.update(""" + UPDATE m_office_profile + SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?, + working_hours_start = ?, working_hours_end = ?, working_days = ?, + ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, + updated_by_username = ?, updated_at = ? + WHERE office_id = ? + """, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, + workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), + Timestamp.from(Instant.now()), officeId); + } else { + jdbcTemplate.update(""" + INSERT INTO m_office_profile + (office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email, + working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, + latitude, longitude, updated_by_username, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, + workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), + Timestamp.from(Instant.now())); + } + + return new CommandProcessingResultBuilder().withEntityId(officeId).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformService.java new file mode 100644 index 000000000..4a544c5c2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformService.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface InstitutionProfileWritePlatformService { + + CommandProcessingResult updateInstitutionProfile(String bankName, String legalName, String shortName, String bankCode, + String institutionCode, String bankType, String ownershipType, String parentInstitutionName, + String registrationNumber, String licenseNumber, String establishedDate, String status, String country, + String regulatoryAuthority, String registrationDate, String licenseStatus, String licenseExpiryDate, + String swiftBic, String addressLine1, String addressLine2, String city, String district, String state, + String pincode, Boolean headOfficeSameAsRegistered, String headOfficeAddress, + Boolean communicationSameAsRegistered, String communicationAddress, String contactPhone, String contactEmail, + String customerCareNumber, String grievanceEmail, String website, String pan, String tan, String gstin, + String statementHeaderName, String passbookHeaderName, String receiptHeaderName, String termsAndConditions, + String authorizedPersonName, String authorizedPersonDesignation, String authorizedPersonContact, + String authorizedPersonEmail); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformServiceImpl.java new file mode 100644 index 000000000..44b1209bf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformServiceImpl.java @@ -0,0 +1,134 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.service; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Moved verbatim out of {@code InstitutionProfileApiResource.update()} so the write can be invoked + * from a {@code NewCommandSourceHandler} for Maker-Checker support. {@code ensureRowExists()}, + * {@code parseDate()} and {@code toSqlDate()} are copies of the resource's own private helpers rather + * than shared ones - the resource's untouched {@code get()} method keeps its own copies so this change + * does not have to touch that unrelated read path at all. + */ +@Service +public class InstitutionProfileWritePlatformServiceImpl implements InstitutionProfileWritePlatformService { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Autowired + public InstitutionProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) { + this.jdbcTemplate = jdbcTemplate; + this.context = context; + } + + @Transactional + @Override + public CommandProcessingResult updateInstitutionProfile(final String bankName, final String legalName, + final String shortName, final String bankCode, final String institutionCode, final String bankType, + final String ownershipType, final String parentInstitutionName, final String registrationNumber, + final String licenseNumber, final String establishedDate, final String status, final String country, + final String regulatoryAuthority, final String registrationDate, final String licenseStatus, + final String licenseExpiryDate, final String swiftBic, final String addressLine1, final String addressLine2, + final String city, final String district, final String state, final String pincode, + final Boolean headOfficeSameAsRegistered, final String headOfficeAddress, + final Boolean communicationSameAsRegistered, final String communicationAddress, final String contactPhone, + final String contactEmail, final String customerCareNumber, final String grievanceEmail, final String website, + final String pan, final String tan, final String gstin, final String statementHeaderName, + final String passbookHeaderName, final String receiptHeaderName, final String termsAndConditions, + final String authorizedPersonName, final String authorizedPersonDesignation, final String authorizedPersonContact, + final String authorizedPersonEmail) { + + ensureRowExists(); + + // A status flip is stamped automatically the first time it happens, rather than asking the + // user to also remember to set a date — the previous row's own status is what "the first + // time" is judged against. + final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1", + String.class); + final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus); + final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus); + final LocalDate today = LocalDate.now(); + + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + UPDATE m_institution_profile + SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?, + bank_type = ?, ownership_type = ?, parent_institution_name = ?, + registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?, + regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?, + swift_bic = ?, + address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?, + head_office_same_as_registered = ?, head_office_address = ?, + communication_same_as_registered = ?, communication_address = ?, + contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?, + website = ?, pan = ?, tan = ?, gstin = ?, + statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?, + terms_and_conditions = ?, + authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?, + authorized_person_email = ?, + activation_date = CASE WHEN ? THEN ? ELSE activation_date END, + deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END, + updated_by_username = ?, updated_at = ? + WHERE id = 1 + """, bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType, + parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(parseDate(establishedDate)), status, country, + regulatoryAuthority, toSqlDate(parseDate(registrationDate)), licenseStatus, toSqlDate(parseDate(licenseExpiryDate)), + swiftBic, addressLine1, addressLine2, city, district, state, pincode, headOfficeSameAsRegistered, + headOfficeAddress, communicationSameAsRegistered, communicationAddress, contactPhone, contactEmail, + customerCareNumber, grievanceEmail, website, pan, tan, gstin, statementHeaderName, passbookHeaderName, + receiptHeaderName, termsAndConditions, authorizedPersonName, authorizedPersonDesignation, + authorizedPersonContact, authorizedPersonEmail, turningActive, toSqlDate(today), turningInactive, + toSqlDate(today), currentUser.getUsername(), Timestamp.from(Instant.now())); + + return new CommandProcessingResultBuilder().withEntityId(1L).build(); + } + + private void ensureRowExists() { + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1", + Integer.class); + if (count == null || count == 0) { + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + INSERT INTO m_institution_profile (id, status, created_by_username, created_at) + VALUES (1, 'ACTIVE', ?, ?) + """, currentUser.getUsername(), Timestamp.from(Instant.now())); + } + } + + private LocalDate parseDate(final String raw) { + return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null; + } + + private Date toSqlDate(final LocalDate d) { + return d == null ? null : Date.valueOf(d); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java index a78c88407..b5325ad94 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java @@ -34,23 +34,19 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.time.LocalDateTime; import java.util.Collection; -import java.util.List; import java.util.Map; import java.util.function.Supplier; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; -import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; -import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/sponsorbanks") @Component @@ -59,8 +55,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; public class SponsorBankApiResource { private final SponsorBankRepository sponsorBankRepository; - private final PlatformSecurityContext context; private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final Gson gson = new GsonBuilder().serializeNulls().create(); /** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */ @@ -125,78 +121,19 @@ public class SponsorBankApiResource { return names; } - /** - * Rejects a payable account that cannot serve as one. - * - * A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be - * a liability account, and it has to accept postings. Catching that here means an administrator - * is told while choosing the account, rather than a clerk discovering it when a draft fails. - */ - private void validateDdPayableGlAccount(final Long glAccountId) { - if (glAccountId == null) { - return; - } - final List> rows = this.jdbcTemplate.queryForList( - "SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?", - glAccountId); - if (rows.isEmpty()) { - throw new IllegalArgumentException("The selected DD Payable GL account does not exist."); - } - final Map gl = rows.get(0); - final String label = gl.get("gl_code") + " · " + gl.get("name"); - // 2 is LIABILITY in Fineract's GLAccountType enum. - if (((Number) gl.get("classification_enum")).intValue() != 2) { - throw new IllegalArgumentException( - "DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. " - + label + " is not a liability account."); - } - // 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header. - if (((Number) gl.get("account_usage")).intValue() == 2) { - throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under " - + label + "."); - } - // Read through both shapes: the column comes back as a Boolean on some drivers and as a - // 0/1 Number on others, and a plain Boolean cast would throw on the latter. - final Object disabled = gl.get("disabled"); - final boolean isDisabled = disabled instanceof Boolean b ? b - : disabled instanceof Number n && n.intValue() != 0; - if (isDisabled) { - throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled."); - } - } - @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.") public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) { + // The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via + // a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see + // CreateSponsorBankCommandHandler. safely() is kept around the call so a validation failure + // still comes back as the same 400 it always has, rather than an unmapped 500. return safely(() -> { - if (request.getIfscCode() != null && !request.getIfscCode().isBlank() - && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { - throw new IllegalArgumentException( - "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); - } - if (request.getName() == null || request.getName().isBlank()) { - throw new IllegalArgumentException("Sponsor Bank Name is required."); - } - validateDdPayableGlAccount(request.getDdPayableGlAccountId()); - final AppUser currentUser = this.context.authenticatedUser(); - // Set field by field rather than through the all-args constructor: that constructor is - // positional, so adding a column silently shifts every argument after it, and the shift - // type-checks whenever two neighbours share a type. - final SponsorBank sponsorBank = new SponsorBank(); - sponsorBank.setName(request.getName().trim()); - sponsorBank.setDescription(request.getDescription()); - sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() - ? request.getIfscCode().trim().toUpperCase() : null); - sponsorBank.setBankCode(request.getBankCode()); - sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() - ? request.getSwiftCode().trim().toUpperCase() : null); - sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); - sponsorBank.setCreatedBy(currentUser); - sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - this.sponsorBankRepository.saveAndFlush(sponsorBank); - return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().createSponsorBank() + .withJson(this.gson.toJson(request)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); }); } @@ -214,27 +151,14 @@ public class SponsorBankApiResource { @Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.") public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId, final SponsorBankRequest request) { + // The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via + // a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see + // UpdateSponsorBankCommandHandler. safely() is kept around the call so a validation failure + // still comes back as the same 400 it always has, rather than an unmapped 500. return safely(() -> { - final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) - .orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found.")); - if (request.getIfscCode() != null && !request.getIfscCode().isBlank() - && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { - throw new IllegalArgumentException( - "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); - } - validateDdPayableGlAccount(request.getDdPayableGlAccountId()); - if (request.getName() != null && !request.getName().isBlank()) { - sponsorBank.setName(request.getName().trim()); - } - sponsorBank.setDescription(request.getDescription()); - sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() - ? request.getIfscCode().trim().toUpperCase() : null); - sponsorBank.setBankCode(request.getBankCode()); - sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() - ? request.getSwiftCode().trim().toUpperCase() : null); - sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); - this.sponsorBankRepository.saveAndFlush(sponsorBank); - return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorBank(sponsorBankId) + .withJson(this.gson.toJson(request)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); }); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index dfa5cba14..664ba319a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -41,6 +41,9 @@ import java.util.Collection; import java.util.List; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; @@ -76,6 +79,7 @@ public class SponsorDDApiResource { private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService; private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PlatformSecurityContext context; private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -539,16 +543,28 @@ public class SponsorDDApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Configure a Sponsor DD GL mapping", description = "Creates or updates the DD Payable / cheque-clearing GL accounts for a sponsor bank / payment type.") public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) { - return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping( - request.getSponsorBankId(), - request.getDdPayableGlAccountId(), - request.getChequeClearingGlAccountId(), - request.getCashGlAccountId(), - request.getChargesIncomeGlAccountId(), - request.getCashSuspenseGlAccountId(), - request.getGstPercentage(), - request.getGstPayableGlAccountId(), - request.getDdLimitAmount())); + // Routed through the generic command-source pipeline (CommandWrapperBuilder -> + // logCommandSource() -> UpdateSponsorDdGlMappingCommandHandler) so this genuinely gains + // Maker-Checker support; the write itself (issueRequestWritePlatformService.upsertGlMapping) + // is unchanged and now called from that handler instead of directly from here. The JSON is + // built by hand (not Gson-serializing `request` directly) purely to include a "locale" key - + // JsonCommand's BigDecimal extractor requires one, and GlMappingRequest has no such field. + return safely(() -> { + final JsonObject json = new JsonObject(); + json.addProperty("sponsorBankId", request.getSponsorBankId()); + json.addProperty("ddPayableGlAccountId", request.getDdPayableGlAccountId()); + json.addProperty("chequeClearingGlAccountId", request.getChequeClearingGlAccountId()); + json.addProperty("cashGlAccountId", request.getCashGlAccountId()); + json.addProperty("chargesIncomeGlAccountId", request.getChargesIncomeGlAccountId()); + json.addProperty("cashSuspenseGlAccountId", request.getCashSuspenseGlAccountId()); + json.addProperty("gstPercentage", request.getGstPercentage()); + json.addProperty("gstPayableGlAccountId", request.getGstPayableGlAccountId()); + json.addProperty("ddLimitAmount", request.getDdLimitAmount()); + json.addProperty("locale", "en"); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorDdGlMapping() + .withJson(this.gson.toJson(json)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + }); } @POST diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/CreateSponsorBankCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/CreateSponsorBankCommandHandler.java new file mode 100644 index 000000000..0cc96b316 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/CreateSponsorBankCommandHandler.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; +import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SPONSORBANK", action = "CREATE") +public class CreateSponsorBankCommandHandler implements NewCommandSourceHandler { + + private final SponsorBankWritePlatformService sponsorBankWritePlatformService; + + @Autowired + public CreateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) { + this.sponsorBankWritePlatformService = sponsorBankWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SponsorBankRequest request = new SponsorBankRequest(); + request.setName(command.stringValueOfParameterNamed("name")); + request.setDescription(command.stringValueOfParameterNamed("description")); + request.setIfscCode(command.stringValueOfParameterNamed("ifscCode")); + request.setBankCode(command.stringValueOfParameterNamed("bankCode")); + request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode")); + request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId")); + + final CommandProcessingResult result = this.sponsorBankWritePlatformService.createSponsorBank(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorBankCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorBankCommandHandler.java new file mode 100644 index 000000000..0b5ecc00d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorBankCommandHandler.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.sponsordd.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; +import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SPONSORBANK", action = "UPDATE") +public class UpdateSponsorBankCommandHandler implements NewCommandSourceHandler { + + private final SponsorBankWritePlatformService sponsorBankWritePlatformService; + + @Autowired + public UpdateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) { + this.sponsorBankWritePlatformService = sponsorBankWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SponsorBankRequest request = new SponsorBankRequest(); + request.setName(command.stringValueOfParameterNamed("name")); + request.setDescription(command.stringValueOfParameterNamed("description")); + request.setIfscCode(command.stringValueOfParameterNamed("ifscCode")); + request.setBankCode(command.stringValueOfParameterNamed("bankCode")); + request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode")); + request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId")); + + final CommandProcessingResult result = this.sponsorBankWritePlatformService.updateSponsorBank(command.entityId(), + request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorDdGlMappingCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorDdGlMappingCommandHandler.java new file mode 100644 index 000000000..6960faeae --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorDdGlMappingCommandHandler.java @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.handler; + +import java.math.BigDecimal; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * The underlying write ({@link SponsorDDIssueRequestWritePlatformService#upsertGlMapping}) is a + * genuine upsert - one call creates the single {@code m_sponsor_dd_gl_mapping} row the first time and + * updates it every time after - so there is exactly one command/permission here rather than a + * separate CREATE and UPDATE, matching that reality instead of inventing a split the endpoint has + * never had. + */ +@Service +@CommandType(entity = "SPONSORDDGLMAPPING", action = "UPDATE") +public class UpdateSponsorDdGlMappingCommandHandler implements NewCommandSourceHandler { + + private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; + + @Autowired + public UpdateSponsorDdGlMappingCommandHandler( + final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService) { + this.issueRequestWritePlatformService = issueRequestWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Long sponsorBankId = command.longValueOfParameterNamed("sponsorBankId"); + final Long ddPayableGlAccountId = command.longValueOfParameterNamed("ddPayableGlAccountId"); + final Long chequeClearingGlAccountId = command.longValueOfParameterNamed("chequeClearingGlAccountId"); + final Long cashGlAccountId = command.longValueOfParameterNamed("cashGlAccountId"); + final Long chargesIncomeGlAccountId = command.longValueOfParameterNamed("chargesIncomeGlAccountId"); + final Long cashSuspenseGlAccountId = command.longValueOfParameterNamed("cashSuspenseGlAccountId"); + final BigDecimal gstPercentage = command.bigDecimalValueOfParameterNamed("gstPercentage"); + final Long gstPayableGlAccountId = command.longValueOfParameterNamed("gstPayableGlAccountId"); + final BigDecimal ddLimitAmount = command.bigDecimalValueOfParameterNamed("ddLimitAmount"); + + final CommandProcessingResult result = this.issueRequestWritePlatformService.upsertGlMapping(sponsorBankId, + ddPayableGlAccountId, chequeClearingGlAccountId, cashGlAccountId, chargesIncomeGlAccountId, + cashSuspenseGlAccountId, gstPercentage, gstPayableGlAccountId, ddLimitAmount); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformService.java new file mode 100644 index 000000000..9078614c8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformService.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.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; + +public interface SponsorBankWritePlatformService { + + CommandProcessingResult createSponsorBank(SponsorBankRequest request); + + CommandProcessingResult updateSponsorBank(Long sponsorBankId, SponsorBankRequest request); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformServiceImpl.java new file mode 100644 index 000000000..04f3fd57e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformServiceImpl.java @@ -0,0 +1,147 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Moved verbatim out of {@code SponsorBankApiResource.createSponsorBank()} and + * {@code updateSponsorBank()} so both writes can be invoked from a {@code NewCommandSourceHandler} + * for Maker-Checker support. {@link #validateDdPayableGlAccount(Long)} is a copy of the resource's + * own (now-unused) validator, kept here since both CREATE and UPDATE rely on it. + */ +@Service +public class SponsorBankWritePlatformServiceImpl implements SponsorBankWritePlatformService { + + private final SponsorBankRepository sponsorBankRepository; + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + + @Autowired + public SponsorBankWritePlatformServiceImpl(final SponsorBankRepository sponsorBankRepository, + final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate) { + this.sponsorBankRepository = sponsorBankRepository; + this.context = context; + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + @Override + public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) { + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + if (request.getName() == null || request.getName().isBlank()) { + throw new IllegalArgumentException("Sponsor Bank Name is required."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); + final AppUser currentUser = this.context.authenticatedUser(); + // Set field by field rather than through the all-args constructor: that constructor is + // positional, so adding a column silently shifts every argument after it, and the shift + // type-checks whenever two neighbours share a type. + final SponsorBank sponsorBank = new SponsorBank(); + sponsorBank.setName(request.getName().trim()); + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); + sponsorBank.setCreatedBy(currentUser); + sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + } + + @Transactional + @Override + public CommandProcessingResult updateSponsorBank(final Long sponsorBankId, final SponsorBankRequest request) { + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) + .orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found.")); + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); + if (request.getName() != null && !request.getName().isBlank()) { + sponsorBank.setName(request.getName().trim()); + } + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + } + + /** Copied from {@code SponsorBankApiResource} - see that class for the full rationale. */ + private void validateDdPayableGlAccount(final Long glAccountId) { + if (glAccountId == null) { + return; + } + final List> rows = this.jdbcTemplate.queryForList( + "SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?", + glAccountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("The selected DD Payable GL account does not exist."); + } + final Map gl = rows.get(0); + final String label = gl.get("gl_code") + " · " + gl.get("name"); + // 2 is LIABILITY in Fineract's GLAccountType enum. + if (((Number) gl.get("classification_enum")).intValue() != 2) { + throw new IllegalArgumentException( + "DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. " + + label + " is not a liability account."); + } + // 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header. + if (((Number) gl.get("account_usage")).intValue() == 2) { + throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under " + + label + "."); + } + // Read through both shapes: the column comes back as a Boolean on some drivers and as a + // 0/1 Number on others, and a plain Boolean cast would throw on the latter. + final Object disabled = gl.get("disabled"); + final boolean isDisabled = disabled instanceof Boolean b ? b + : disabled instanceof Number n && n.intValue() != 0; + if (isDisabled) { + throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java index 9554f8cdb..6b5345abb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java @@ -97,15 +97,34 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform + " order by p.grouping, coalesce(entity_name, ''), p.code"; } + /** + * Permission codes whose approval workflow is a bespoke, unconditional pending-request mechanism + * (m_user_pending_request / m_share_account_pending_edit / m_current_account_pending_edit / + * m_client_pending_edit) that never reads {@code can_maker_checker} - verified by inspecting every + * write-service on this path (UserPendingWritePlatformServiceImpl, ShareAccountEditWritePlatformServiceJpaRepositoryImpl, + * CurrentAccountEditWritePlatformServiceJpaRepositoryImpl, and the client edit equivalent): none of + * them consult ConfigurationDomainService in any way. Toggling this checkbox on the Configure + * Maker-Checker Tasks screen therefore has zero effect on these tasks in either direction, so they + * are excluded here rather than presented as configurable when they are not. CREATE_USER/UPDATE_USER + * remain governed by the separate global "maker-checker" configuration switch (System -> + * Configurations), not by this per-task flag. + */ + private static final String DEAD_MAKER_CHECKER_CODES = "'CREATE_USER','UPDATE_USER'," + + "'SUBMITEDIT_SHAREACCOUNT','APPROVEEDIT_SHAREACCOUNT','REJECTEDIT_SHAREACCOUNT'," + + "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT'," + + "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'"; + public String makerCheckerablePermissionSchema() { /* * get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping, - * the READ permissions and the CHECKER permissions + * the READ permissions and the CHECKER permissions), and exclude permission codes whose approval + * workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above). */ return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected" + " from m_permission p " + " where " + sqlGenerator.escape("grouping") + " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'" + + " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")" + " order by p.grouping, coalesce(entity_name, ''), p.code"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java index 08d2f1038..e767db338 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java @@ -36,7 +36,13 @@ import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.domain.JdbcSupport; import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.organisation.staff.domain.Staff; +import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper; import org.apache.fineract.useradministration.data.UserPendingRequestData; +import org.apache.fineract.useradministration.domain.Role; +import org.apache.fineract.useradministration.domain.RoleRepository; import org.apache.fineract.useradministration.domain.UserPendingRequest; import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper; import org.springframework.dao.EmptyResultDataAccessException; @@ -51,8 +57,13 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo private final JdbcTemplate jdbcTemplate; private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper; + private final OfficeRepositoryWrapper officeRepositoryWrapper; + private final StaffRepositoryWrapper staffRepositoryWrapper; + private final RoleRepository roleRepository; private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson(); + private static final String UNKNOWN_REFERENCE_FORMAT = "Unknown (ID: %s)"; + private static final String SELECT_SQL = """ SELECT r.id AS id, r.user_id AS userId, @@ -115,7 +126,96 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo public UserPendingRequestData retrievePendingRequest(final Long id) { final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); final String sql = SELECT_SQL + " WHERE r.id = ?"; - return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id); + final UserPendingRequestData data = this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id); + resolveDisplayNames(data); + return data; + } + + /** + * Resolves office/staff/role ids on the pending-user comparison payload to human-readable names, and folds + * the point-in-time original-side name fields (officeName/staffName/roleNames) into their corresponding + * id-keyed rows so the compare dialog shows one row per field instead of a redundant name-only row. + */ + private void resolveDisplayNames(final UserPendingRequestData data) { + final Map originalValues = data.getOriginalValues(); + final Map proposedValues = data.getProposedValues(); + + // CREATE_USER requests have no original snapshot - nothing to fold on that side. + if (originalValues != null && !originalValues.isEmpty()) { + foldNameIntoId(originalValues, "officeName", "officeId"); + foldNameIntoId(originalValues, "staffName", "staffId"); + if (originalValues.containsKey("roleNames")) { + originalValues.put("roles", sortNames(toStringList(originalValues.get("roleNames")))); + originalValues.remove("roleNames"); + } + } + + if (proposedValues != null && !proposedValues.isEmpty()) { + if (proposedValues.get("officeId") instanceof Number officeIdNum) { + proposedValues.put("officeId", resolveOfficeName(officeIdNum.longValue())); + } + if (proposedValues.get("staffId") instanceof Number staffIdNum) { + proposedValues.put("staffId", resolveStaffName(staffIdNum.longValue())); + } + if (proposedValues.get("roles") instanceof Collection rolesCollection) { + proposedValues.put("roles", sortNames(resolveRoleNames(toStringList(rolesCollection)))); + } + } + } + + private static void foldNameIntoId(final Map values, final String nameKey, final String idKey) { + if (values.containsKey(nameKey)) { + values.put(idKey, values.get(nameKey)); + values.remove(nameKey); + } + } + + private String resolveOfficeName(final Long officeId) { + try { + final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + return office.getName(); + } catch (final RuntimeException e) { + return String.format(UNKNOWN_REFERENCE_FORMAT, officeId); + } + } + + private String resolveStaffName(final Long staffId) { + try { + final Staff staff = this.staffRepositoryWrapper.findOneWithNotFoundDetection(staffId); + return staff.displayName(); + } catch (final RuntimeException e) { + return String.format(UNKNOWN_REFERENCE_FORMAT, staffId); + } + } + + private List resolveRoleNames(final List roleIds) { + final List names = new ArrayList<>(); + for (final String roleIdStr : roleIds) { + try { + final Long roleId = Long.valueOf(roleIdStr); + final Role role = this.roleRepository.findById(roleId).orElse(null); + names.add(role != null ? role.getName() : String.format(UNKNOWN_REFERENCE_FORMAT, roleId)); + } catch (final NumberFormatException e) { + names.add(roleIdStr); + } + } + return names; + } + + private static List sortNames(final List names) { + final List sorted = new ArrayList<>(names); + sorted.sort(String.CASE_INSENSITIVE_ORDER); + return sorted; + } + + private static List toStringList(final Object value) { + final List result = new ArrayList<>(); + if (value instanceof Collection collection) { + for (final Object item : collection) { + result.add(String.valueOf(item)); + } + } + return result; } private static class UserPendingRowMapper implements RowMapper { diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java index 00c235802..3806b6b24 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java @@ -162,8 +162,8 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null); originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null); originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null); + originalSnapshot.put("staffName", userToUpdate.getStaff() != null ? userToUpdate.getStaff().displayName() : null); originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires()); - originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser()); final Set roleNames = new HashSet<>(); final Set roleIds = new HashSet<>(); if (userToUpdate.getRoles() != null) { diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 020cdbd2f..b6ac7ca48 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 545b6450b..361f2e488 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -116,4 +116,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_add_sponsor_dd_gl_mapping_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_add_sponsor_dd_gl_mapping_permission.xml new file mode 100644 index 000000000..3f5a9552a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_add_sponsor_dd_gl_mapping_permission.xml @@ -0,0 +1,45 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SPONSORDDGLMAPPING' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'UPDATE_SPONSORDDGLMAPPING' + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_sponsor_bank_update_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_sponsor_bank_update_permission.xml new file mode 100644 index 000000000..1006408c2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_sponsor_bank_update_permission.xml @@ -0,0 +1,44 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SPONSORBANK' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, p.id + FROM m_role_permission rp + JOIN m_permission existing ON existing.id = rp.permission_id AND existing.code = 'CREATE_SPONSORBANK' + CROSS JOIN m_permission p + WHERE p.code = 'UPDATE_SPONSORBANK' + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id + ) + + + + From 4b9093007fde65c1e833f7ec39c6cd4aeba0931b Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 24 Sep 2026 18:05:58 +0530 Subject: [PATCH 06/39] feat: update TDS config, RBI reserve, current products, OSS returns, and dormancy services --- ...ToGLAccountMappingReadPlatformService.java | 2 + ...AccountMappingReadPlatformServiceImpl.java | 14 + .../portfolio/PortfolioProductType.java | 5 +- .../savings/SavingsApiConstants.java | 9 + .../savings/data/SavingsProductData.java | 10 + .../currentaccount/domain/CurrentAccount.java | 3 +- .../domain/CurrentAccountTransaction.java | 16 +- ...ccountTransactionWritePlatformService.java | 6 + .../data/CurrentProductDataValidator.java | 128 +- .../currentproduct/domain/CurrentProduct.java | 68 + .../domain/CurrentProductAssembler.java | 22 + .../CurrentProductGlMappingWriter.java | 115 + ...CurrentProductReadPlatformServiceImpl.java | 8 + ...WritePlatformServiceJpaRepositoryImpl.java | 12 +- .../starter/CurrentProductConfiguration.java | 5 +- .../service/AccountingProcessorHelper.java | 3276 +++++++++-------- .../service/AccountBookDebitService.java | 152 + ...ountTransfersWritePlatformServiceImpl.java | 47 +- .../account/starter/AccountConfiguration.java | 281 +- .../ChequeBookIssuanceChargeService.java | 2 +- .../ClearingGlAccountResolverService.java | 2 +- .../ClerkDisbursementRequestApiResource.java | 133 +- .../client/service/KycRestrictionGuard.java | 57 +- .../ossreturn/api/OssReturnApiResource.java | 850 +++++ .../api/RbiReserveConfigApiResource.java | 687 ++++ .../api/RbiReserveReturnApiResource.java | 892 +++++ .../api/CurrentProductsApiResource.java | 14 +- .../savings/data/DormantAccountData.java | 2 + .../UpdateSavingsDormantAccountsTasklet.java | 5 + .../service/CurrentAccountCashGlResolver.java | 53 + .../CurrentAccountDormancyJobService.java | 95 + ...CurrentAccountReadPlatformServiceImpl.java | 82 +- ...ntTransactionWritePlatformServiceImpl.java | 125 +- ...urrentAccountWritePlatformServiceImpl.java | 1547 ++++---- .../savings/service/DormancyClock.java | 27 +- ...DormantAccountReadPlatformServiceImpl.java | 38 +- ...DIssueRequestWritePlatformServiceImpl.java | 53 +- .../tds/api/TdsConfigApiResource.java | 383 ++ .../tenant/final-changelog-tenant.xml | 13 + .../parts/0571_create_rbi_reserve_config.xml | 213 ++ .../0572_create_oss_return_permissions.xml | 80 + ...not_null_on_sponsor_dd_mapping_payable.xml | 47 + .../parts/0574_rbi_reserve_ndtl_weighting.xml | 180 + .../parts/0575_rbi_reserve_custom_roles.xml | 105 + .../0576_widen_rbi_reserve_rate_type.xml | 47 + ...7_add_current_product_od_scheme_fields.xml | 71 + .../0578_grant_cashier_read_codevalue.xml | 50 + ...t_product_gl_mappings_own_product_type.xml | 82 + .../0580_unique_core_product_gl_mapping.xml | 62 + .../tenant/parts/0581_form_a_completion.xml | 386 ++ .../tenant/parts/0582_create_tds_config.xml | 252 ++ ...SavingsProductAccountingDataValidator.java | 9 +- .../savings/domain/CurrentOnlyEditFields.java | 16 + .../domain/ProductPendingEditFieldMapper.java | 25 + .../savings/parts/module-changelog-master.xml | 1 + ...rent_product_od_scheme_pending_columns.xml | 58 + 56 files changed, 8174 insertions(+), 2749 deletions(-) create mode 100644 fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java index 1cfba6559..365ffe441 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java @@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService { Map fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType); + Map fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType); + List fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId); List fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId); diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java index 8c6ed8881..65b754870 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java @@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product return accountMappingDetails; } + /** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */ + @Override + public Map fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) { + final List mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId, + PortfolioProductType.CURRENT.getValue()); + if (AccountingValidations.isCashBasedAccounting(accountingType)) { + return setCashSavingsProductToGLAccountMaps(mappings); + } + if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) { + return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings); + } + return null; + } + @Override public List fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) { return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java index 7b2bf1c4f..fc6f313f3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java @@ -24,7 +24,10 @@ public enum PortfolioProductType { SAVING(2, "productType.saving"), // CLIENT(5, "productType.client"), // PROVISIONING(3, "productType.provisioning"), // - SHARES(4, "productType.shares"); // + SHARES(4, "productType.shares"), // + // Current products map their GLs under their own type: sharing SAVING with savings products let a savings + // product and a current product with the same id read each other's GL rows. + CURRENT(6, "productType.current"); // private final Integer value; private final String code; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java index f47a04cf5..0dd86849f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java @@ -286,6 +286,15 @@ public class SavingsApiConstants { public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod"; public static final String validForPeriodParamName = "validForPeriod"; public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing"; + // Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an + // amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either. + public static final String odMinLimitParamName = "odMinLimit"; + public static final String odSecurityTypeParamName = "odSecurityType"; + // Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the + // limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess. + public static final String odExcessAllowedParamName = "odExcessAllowed"; + public static final String odExcessLimitPercentParamName = "odExcessLimitPercent"; + public static final String odPenalRateParamName = "odPenalRate"; // Status of Account fields public static final String accountStatusParamName = "accountStatus"; 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 0c74cde3c..f508d3227 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 @@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable { @Getter @Setter private String avgBalanceCalcPeriod; @Getter @Setter private String validForPeriod; @Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing; + @Getter @Setter private BigDecimal odMinLimit; + @Getter @Setter private String odSecurityType; + @Getter @Setter private Boolean odExcessAllowed; + @Getter @Setter private BigDecimal odExcessLimitPercent; + @Getter @Setter private BigDecimal odPenalRate; // Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects @Getter @Setter private Map accountFacilityConfig; @@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable { target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod()); target.setValidForPeriod(source.getValidForPeriod()); target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing()); + target.setOdMinLimit(source.getOdMinLimit()); + target.setOdSecurityType(source.getOdSecurityType()); + target.setOdExcessAllowed(source.getOdExcessAllowed()); + target.setOdExcessLimitPercent(source.getOdExcessLimitPercent()); + target.setOdPenalRate(source.getOdPenalRate()); target.setOtherGlMappingsMap(source.getOtherGlMappingsMap()); target.setAccountFacilityConfig(source.getAccountFacilityConfig()); target.setAccountFacilityLimits(source.getAccountFacilityLimits()); diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java index 38bf40627..2a72e7916 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java @@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom { @Column(name = "status_enum", nullable = false) private Integer statusEnum = 100; - // SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit + // SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit, + // 600=BlockDebit @Column(name = "sub_status_enum", nullable = false) private Integer subStatusEnum = 0; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java index 60f4eb073..bf9c21709 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java @@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom { @JoinColumn(name = "office_id", nullable = false) private Office office; - // 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal + // 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat @Column(name = "transaction_type_enum", nullable = false) private Integer transactionTypeEnum; @@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom { return txn; } + // transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings + public static CurrentAccountTransaction escheat(CurrentAccount account, Office office, + BigDecimal amount, LocalDate transactionDate) { + CurrentAccountTransaction txn = new CurrentAccountTransaction(); + txn.currentAccount = account; + txn.office = office; + txn.transactionTypeEnum = 19; + txn.amount = amount; + txn.transactionDate = transactionDate; + txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability"; + txn.submittedOnDate = transactionDate; + return txn; + } + // transactionTypeEnum = 7 (Reversal / Undo) public static CurrentAccountTransaction reversal(CurrentAccount account, Office office, BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) { diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java index d860b7fb0..2533688f7 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java @@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService { CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command); CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command); + + /** + * Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the + * nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done. + */ + boolean escheat(Long accountId); } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java index 8d09d6b64..859841b83 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java @@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; @@ -136,7 +142,17 @@ public class CurrentProductDataValidator { SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName, SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName, SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName, - SavingsApiConstants.glAccountIdParamName)); + odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName, + odPenalRateParamName, SavingsApiConstants.glAccountIdParamName)); + + /** How long an OD sanction on this scheme runs before it has to be renewed. */ + private static final List OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y"); + /** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */ + private static final List OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH"); + /** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */ + private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50"); + /** Excess drawing is a small tolerance over the limit, never a second limit. */ + private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25"); public void validateForCreate(final String json) { if (StringUtils.isBlank(json)) { @@ -260,10 +276,10 @@ public class CurrentProductDataValidator { if (AccountingValidations.isCashBasedAccounting(accountingRuleType) || AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) { savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element, - baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true); + baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true); } - validateOverdraftParams(baseDataValidator, element); + validateOverdraftParams(baseDataValidator, element, null); if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) { final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element); @@ -358,7 +374,7 @@ public class CurrentProductDataValidator { if (AccountingValidations.isCashBasedAccounting(accountingRuleType) || AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) { savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element, - baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false); + baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false); } if (null != isDormancyActive && isDormancyActive) { @@ -380,7 +396,7 @@ public class CurrentProductDataValidator { } } - validateOverdraftParams(baseDataValidator, element); + validateOverdraftParams(baseDataValidator, element, product); if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) { final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element); @@ -398,23 +414,103 @@ public class CurrentProductDataValidator { } } - private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) { + /** + * The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is + * allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate, + * a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly, + * as RBI requires for advances, or at most quarterly. + * + * On update, product is the stored product and fills in any value the request leaves out, so a partial edit is + * checked against the scheme it will produce; on create it is null. + */ + private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element, + final CurrentProduct product) { if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) { final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element); baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue(); } - if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) { - final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element); - baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount(); + final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element) + ? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element)) + : product != null && product.isAllowOverdraft(); + + final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName, + product == null ? null : product.getOverdraftLimit()); + final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit()); + final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName, + product == null ? null : product.getNominalAnnualInterestRateOverdraft()); + final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName, + product == null ? null : product.getMinOverdraftForInterestCalculation()); + + if (!allowOverdraft) { + baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull() + .zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull() + .zeroOrPositiveAmount(); + return; } - if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) { - final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element); - baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount(); + + baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount(); + baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount(); + if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) { + baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit) + .failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit."); } - if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) { - final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element); - baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount() + .notGreaterThanMax(MAX_OVERDRAFT_RATE); + baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull() + .zeroOrPositiveAmount(); + + final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod()); + baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank() + .isOneOfTheseStringValues(OD_VALIDITY_PERIODS); + + final String securityType = stringOrStored(element, odSecurityTypeParamName, + product == null ? null : product.getOdSecurityType()); + baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank() + .isOneOfTheseStringValues(OD_SECURITY_TYPES); + + // Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the + // excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate + // charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance. + final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element) + ? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element)) + : product != null && product.isOdExcessAllowed(); + if (excessAllowed) { + final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName, + product == null ? null : product.getOdExcessLimitPercent()); + baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount() + .notGreaterThanMax(MAX_EXCESS_PERCENT); + final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName, + product == null ? null : product.getOdPenalRate()); + baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount() + .notGreaterThanMax(MAX_OVERDRAFT_RATE); } + + final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element) + ? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element) + : product == null ? null : product.getInterestPostingPeriodType(); + baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull() + .isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(), + SavingsPostingInterestPeriodType.QUATERLY.getValue()); + + final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element) + ? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element) + : product == null ? null : product.getInterestCalculationDaysInYearType(); + baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull() + .isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(), + SavingsInterestCalculationDaysInYearType.DAYS_365.getValue()); + } + + private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) { + return this.fromApiJsonHelper.parameterExists(paramName, element) + ? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element) + : stored; + } + + private String stringOrStored(final JsonElement element, final String paramName, final String stored) { + return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element) + : stored; } private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) { @@ -430,7 +526,7 @@ public class CurrentProductDataValidator { && BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) { final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element); final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element); - if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) { + if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) { baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit"); } } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java index 947885750..4a632868f 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java @@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; import com.google.gson.JsonArray; import jakarta.persistence.CascadeType; @@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom { @Column(name = "od_account_closed_zero_balance_in_day_closing") private boolean odAccountClosedZeroBalanceInDayClosing = false; + // The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest. + @Column(name = "od_min_limit", scale = 6, precision = 19) + private BigDecimal odMinLimit; + + // CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...). + @Column(name = "od_security_type", length = 20) + private String odSecurityType; + + // Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess + // as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone. + @Column(name = "od_excess_allowed") + private boolean odExcessAllowed = false; + + @Column(name = "od_excess_limit_percent", scale = 6, precision = 19) + private BigDecimal odExcessLimitPercent; + + @Column(name = "od_penal_rate", scale = 6, precision = 19) + private BigDecimal odPenalRate; + @OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true) private CurrentProductFacilityConfig facilityConfig; @@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom { actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue); this.odAccountClosedZeroBalanceInDayClosing = newValue; } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName); + actualChanges.put(odMinLimitParamName, newValue); + this.odMinLimit = newValue; + } + if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) { + final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName); + actualChanges.put(odSecurityTypeParamName, newValue); + this.odSecurityType = newValue; + } + if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName); + actualChanges.put(odExcessAllowedParamName, newValue); + this.odExcessAllowed = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName); + actualChanges.put(odExcessLimitPercentParamName, newValue); + this.odExcessLimitPercent = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName); + actualChanges.put(odPenalRateParamName, newValue); + this.odPenalRate = newValue; + } + if (!this.odExcessAllowed) { + this.odExcessLimitPercent = null; + this.odPenalRate = null; + } // Clear OD-specific fields when overdraft is disabled if (!this.allowOverdraft) { this.avgBalanceCalcPeriod = null; this.validForPeriod = null; this.odAccountClosedZeroBalanceInDayClosing = false; + this.odMinLimit = null; + this.odSecurityType = null; + this.odExcessAllowed = false; + this.odExcessLimitPercent = null; + this.odPenalRate = null; } return actualChanges; @@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom { public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; } public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; } public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; } + public BigDecimal getOdMinLimit() { return odMinLimit; } + public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; } + public String getOdSecurityType() { return odSecurityType; } + public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; } + public boolean isOdExcessAllowed() { return odExcessAllowed; } + public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; } + public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; } + public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; } + public BigDecimal getOdPenalRate() { return odPenalRate; } + public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; } } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java index 4bdecc85c..29ed7c5ee 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java @@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName; @@ -314,6 +319,23 @@ public class CurrentProductAssembler { if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) { product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName)); } + if (command.parameterExists(odMinLimitParamName)) { + product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName)); + } + if (command.parameterExists(odSecurityTypeParamName)) { + product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName)); + } + final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName) + && command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName); + product.setOdExcessAllowed(excessAllowed); + if (excessAllowed) { + if (command.parameterExists(odExcessLimitPercentParamName)) { + product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName)); + } + if (command.parameterExists(odPenalRateParamName)) { + product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName)); + } + } } return product; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java new file mode 100644 index 000000000..b41d3abcc --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java @@ -0,0 +1,115 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.currentproduct.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}. + * + *

+ * They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product + * and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the + * account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type, + * the two can never meet. + * + *

+ * A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a + * partial edit changes only what it names. Accounting rule NONE clears every mapping. + */ +@Component +@RequiredArgsConstructor +public class CurrentProductGlMappingWriter { + + /** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */ + private static final Map PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>(); + + static { + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18); + } + + private static final int CURRENT = PortfolioProductType.CURRENT.getValue(); + + private final JdbcTemplate jdbcTemplate; + + /** + * Applies the request's mappings to the product and returns what changed, keyed by parameter name. + * + * @param accountingRule + * the product's accounting rule after this request (1 = NONE) + */ + public Map write(final Long productId, final JsonCommand command, final Integer accountingRule) { + final Map changes = new LinkedHashMap<>(); + if (accountingRule == null || accountingRule == 1) { + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId); + return changes; + } + final JsonElement parsed = command.parsedJson(); + final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + for (final Map.Entry entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) { + final String param = entry.getKey(); + if (!json.has(param)) { + continue; + } + final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong(); + final Long existing = jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?" + + " AND payment_type IS NULL AND charge_id IS NULL", + Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null); + if (java.util.Objects.equals(existing, glAccountId)) { + continue; + } + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?" + + " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue()); + if (glAccountId != null) { + jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)" + + " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue()); + } + changes.put(param, glAccountId); + } + return changes; + } + + /** Removes every mapping of a deleted product. */ + public void deleteAll(final Long productId) { + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId); + } +} 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 e0f22d7e5..befebaa50 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 @@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, "); sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, "); sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, "); + sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, "); + sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, "); + sqlBuilder.append("cp.od_penal_rate as odPenalRate, "); sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id " + "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval "); @@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod")); productData.setValidForPeriod(rs.getString("validForPeriod")); productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing")); + productData.setOdMinLimit(rs.getBigDecimal("odMinLimit")); + productData.setOdSecurityType(rs.getString("odSecurityType")); + productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed")); + productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent")); + productData.setOdPenalRate(rs.getBigDecimal("odPenalRate")); productData.setIsPendingApproval(rs.getBoolean("isPendingApproval")); return productData; 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 7ef44cd2c..02b747fb2 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 @@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository; private final FromJsonHelper fromApiJsonHelper; private final CommandSourceRepository commandSourceRepository; + private final CurrentProductGlMappingWriter glMappingWriter; private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) { String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME; @@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr this.depositLimitKycRepository.saveAndFlush(depositLimitKyc); } - this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command, - DepositAccountType.SAVINGS_DEPOSIT); + // Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter. + this.glMappingWriter.write(product.getId(), command, product.getAccountingType()); return new CommandProcessingResultBuilder() // .withEntityId(product.getId()) // @@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr } // accounting related changes - final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName); - final Map accountingMappingChanges = this.accountMappingWritePlatformService - .updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(), - DepositAccountType.SAVINGS_DEPOSIT); + final Map accountingMappingChanges = this.glMappingWriter.write(product.getId(), command, + product.getAccountingType()); changes.putAll(accountingMappingChanges); if (!changes.isEmpty()) { @@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr .orElseThrow(() -> new SavingsProductNotFoundException(productId)); this.currentProductRepository.delete(product); + this.glMappingWriter.deleteAll(productId); return new CommandProcessingResultBuilder() // .withEntityId(product.getId()) // 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 8b7af3502..4a59f90d2 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 @@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; +import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService; @@ -78,11 +79,11 @@ public class CurrentProductConfiguration { ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository, ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, FromJsonHelper fromApiJsonHelper, - CommandSourceRepository commandSourceRepository) { + CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) { return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator, currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository, currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, - pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository); + pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 2c6e8888c..f8235a2a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -1,1618 +1,1658 @@ -/** - * 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.accounting.journalentry.service; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.apache.fineract.accounting.closure.domain.GLClosure; -import org.apache.fineract.accounting.closure.domain.GLClosureRepository; -import org.apache.fineract.accounting.common.AccountingConstants; -import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan; -import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares; -import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; -import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams; -import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; -import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; -import org.apache.fineract.accounting.glaccount.domain.GLAccount; -import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; -import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; -import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; -import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.LoanDTO; -import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.SavingsDTO; -import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.SharesDTO; -import org.apache.fineract.accounting.journalentry.data.SharesTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.TaxPaymentDTO; -import org.apache.fineract.accounting.journalentry.domain.JournalEntry; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; -import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException; -import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException.GlJournalEntryInvalidReason; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; -import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException; -import org.apache.fineract.infrastructure.core.data.EnumOptionData; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.infrastructure.event.business.domain.journalentry.LoanJournalEntryCreatedBusinessEvent; -import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.office.domain.OfficeRepository; -import org.apache.fineract.portfolio.PortfolioProductType; -import org.apache.fineract.portfolio.account.PortfolioAccountType; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; -import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; -import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; -import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; -import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; -import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; -import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; -import org.springframework.dao.DataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; - -@RequiredArgsConstructor -public class AccountingProcessorHelper { - - public static final String LOAN_TRANSACTION_IDENTIFIER = "L"; - public static final String SAVINGS_TRANSACTION_IDENTIFIER = "S"; - public static final String FIXED_DEPOSIT_TRANSACTION_IDENTIFIER = "FD"; - public static final String RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER = "RD"; - public static final String CLIENT_TRANSACTION_IDENTIFIER = "C"; - public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; - public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; - - /** - * Reserved names on the savings product's Accounting tab, naming the two accounts an interest - * posting is routed through. The names are the contract with the product screen; the GL codes - * behind them are the bank's choice, so no code is named here. - */ - public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable"; - public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid"; - - private static final ThreadLocal DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); - - public static void setDepositAccountTypeContext(int depositAccountType) { - DEPOSIT_ACCOUNT_TYPE_CONTEXT.set(depositAccountType); - } - - public static void clearDepositAccountTypeContext() { - DEPOSIT_ACCOUNT_TYPE_CONTEXT.remove(); - } - - private String getSavingsTransactionIdentifier() { - return switch (DEPOSIT_ACCOUNT_TYPE_CONTEXT.get()) { - case 200 -> FIXED_DEPOSIT_TRANSACTION_IDENTIFIER; - case 300 -> RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER; - default -> SAVINGS_TRANSACTION_IDENTIFIER; - }; - } - - private final JournalEntryRepository glJournalEntryRepository; - private final ProductToGLAccountMappingRepository accountMappingRepository; - private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; - private final GLClosureRepository closureRepository; - private final GLAccountRepository glAccountRepository; - private final OfficeRepository officeRepository; - private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; - private final ChargeRepositoryWrapper chargeRepositoryWrapper; - private final BusinessEventNotifierService businessEventNotifierService; - // The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a - // JSON document rather than in acc_product_mapping, so they are read directly. See - // namedSavingsGlAccount. - private final JdbcTemplate jdbcTemplate; - - public LoanDTO populateLoanDtoFromDTO( - final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { - final Long loanId = accountingBridgeData.getLoanId(); - final Long loanProductId = accountingBridgeData.getLoanProductId(); - final Long officeId = accountingBridgeData.getOfficeId(); - final String currencyCode = accountingBridgeData.getCurrencyCode(); - final List newLoanTransactions = new ArrayList<>(); - boolean isAccountTransfer = accountingBridgeData.isAccountTransfer(); - boolean isLoanMarkedAsChargeOff = accountingBridgeData.isChargeOff(); - boolean isLoanMarkedAsFraud = accountingBridgeData.isFraud(); - final Long chargeOffReasonCodeValue = accountingBridgeData.getChargeOffReasonCodeValue(); - final boolean isLoanMarkedAsWrittenOff = accountingBridgeData.isWrittenOff(); - final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled(); - final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled(); - final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled(); - final boolean merchantBuyDownFee = accountingBridgeData.isMerchantBuyDownFee(); - - final List loanTransactionDTOs = accountingBridgeData.getNewLoanTransactions(); - - for (final AccountingBridgeLoanTransactionDTO loanTxnDto : loanTransactionDTOs) { - final Long transactionOfficeId = loanTxnDto.getOfficeId(); - final String transactionId = loanTxnDto.getId().toString(); - final LocalDate transactionDate = loanTxnDto.getDate(); - final LoanTransactionEnumData transactionType = loanTxnDto.getType(); - final BigDecimal amount = loanTxnDto.getAmount(); - final BigDecimal principal = loanTxnDto.getPrincipalPortion(); - final BigDecimal interest = loanTxnDto.getInterestPortion(); - final BigDecimal fees = loanTxnDto.getFeeChargesPortion(); - final BigDecimal penalties = loanTxnDto.getPenaltyChargesPortion(); - final BigDecimal overPayments = loanTxnDto.getOverPaymentPortion(); - final boolean reversed = loanTxnDto.isReversed(); - final Long paymentTypeId = loanTxnDto.getPaymentTypeId(); - final String chargeRefundChargeType = loanTxnDto.getChargeRefundChargeType(); - final LoanChargeData loanChargeData = loanTxnDto.getLoanChargeData(); - - final List feePaymentDetails = new ArrayList<>(); - final List penaltyPaymentDetails = new ArrayList<>(); - // extract charge payment details (if exists) - if (loanTxnDto.getLoanChargesPaid() != null) { - List loanChargesPaidData = loanTxnDto.getLoanChargesPaid(); - for (final LoanChargePaidByDTO loanChargePaid : loanChargesPaidData) { - final Long chargeId = loanChargePaid.getChargeId(); - final Long loanChargeId = loanChargePaid.getLoanChargeId(); - final boolean isPenalty = loanChargePaid.getIsPenalty(); - final BigDecimal chargeAmountPaid = loanChargePaid.getAmount(); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - if (isPenalty) { - penaltyPaymentDetails.add(chargePaymentDTO); - } else { - feePaymentDetails.add(chargePaymentDTO); - } - } - } - - boolean localIsAccountTransfer = isAccountTransfer; - if (!localIsAccountTransfer) { - localIsAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), - PortfolioAccountType.LOAN); - } - - BigDecimal principalPaid = loanTxnDto.getPrincipalPaid(); - BigDecimal feePaid = loanTxnDto.getFeePaid(); - BigDecimal penaltyPaid = loanTxnDto.getPenaltyPaid(); - - final LoanTransactionDTO transaction = new LoanTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, amount, principal, interest, fees, penalties, overPayments, reversed, - penaltyPaymentDetails, feePaymentDetails, localIsAccountTransfer, chargeRefundChargeType, loanChargeData, principalPaid, - feePaid, penaltyPaid); - - transaction.setLoanToLoanTransfer(loanTxnDto.isLoanToLoanTransfer()); - newLoanTransactions.add(transaction); - } - - return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled, - periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud, - chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff, merchantBuyDownFee, - accountingBridgeData.getBuydownFeeClassificationCodeValue(), - accountingBridgeData.getCapitalizedIncomeClassificationCodeValue(), accountingBridgeData.getWriteOffReasonCodeValue()); - } - - public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - Long chargeOffReasonId) { - return accountMappingRepository.findChargeOffReasonMapping(loanProductId, productType.getValue(), chargeOffReasonId); - } - - public ProductToGLAccountMapping getWriteOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - Long writeOffReasonId) { - return accountMappingRepository.findWriteOffReasonMapping(loanProductId, productType.getValue(), writeOffReasonId); - } - - public ProductToGLAccountMapping getClassificationMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - final Long classificationId, final String classificationType) { - if (LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue().equals(classificationType)) { - return accountMappingRepository.findBuydownFeeClassificationMapping(loanProductId, productType.getValue(), classificationId); - } else { - return accountMappingRepository.findCapitalizedIncomeClassificationMapping(loanProductId, productType.getValue(), - classificationId); - } - } - - public SavingsDTO populateSavingsDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, - final boolean accrualBasedAccountingEnabled) { - final Long loanId = (Long) accountingBridgeData.get("savingsId"); - final Long loanProductId = (Long) accountingBridgeData.get("savingsProductId"); - final Long officeId = (Long) accountingBridgeData.get("officeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final int depositAccountType = accountingBridgeData.containsKey("depositAccountType") - ? (Integer) accountingBridgeData.get("depositAccountType") - : 100; - final List newSavingsTransactions = new ArrayList<>(); - boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer"); - - @SuppressWarnings("unchecked") - final List> newTransactionsMap = (List>) accountingBridgeData.get("newSavingsTransactions"); - - for (final Map map : newTransactionsMap) { - final Long transactionOfficeId = (Long) map.get("officeId"); - final String transactionId = ((Long) map.get("id")).toString(); - final LocalDate transactionDate = ((LocalDate) map.get("date")); - final SavingsAccountTransactionEnumData transactionType = (SavingsAccountTransactionEnumData) map.get("type"); - final BigDecimal amount = (BigDecimal) map.get("amount"); - final boolean reversed = (Boolean) map.get("reversed"); - final Long paymentTypeId = (Long) map.get("paymentTypeId"); - final BigDecimal overdraftAmount = (BigDecimal) map.get("overdraftAmount"); - - final List feePayments = new ArrayList<>(); - final List penaltyPayments = new ArrayList<>(); - // extract charge payment details (if exists) - if (map.containsKey("savingsChargesPaid")) { - @SuppressWarnings("unchecked") - final List> savingsChargesPaidData = (List>) map.get("savingsChargesPaid"); - for (final Map loanChargePaid : savingsChargesPaidData) { - final Long chargeId = (Long) loanChargePaid.get("chargeId"); - final Long loanChargeId = (Long) loanChargePaid.get("savingsChargeId"); - final boolean isPenalty = (Boolean) loanChargePaid.get("isPenalty"); - final BigDecimal chargeAmountPaid = (BigDecimal) loanChargePaid.get("amount"); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - if (isPenalty) { - penaltyPayments.add(chargePaymentDTO); - } else { - feePayments.add(chargePaymentDTO); - } - } - } - - final List taxPayments = new ArrayList<>(); - if (map.containsKey("taxDetails")) { - @SuppressWarnings("unchecked") - final List> taxDataList = (List>) map.get("taxDetails"); - for (final Map taxData : taxDataList) { - final BigDecimal taxAmount = (BigDecimal) taxData.get("amount"); - final Long creditAccountId = (Long) taxData.get("creditAccountId"); - final Long debitAccountId = (Long) taxData.get("debitAccountId"); - taxPayments.add(new TaxPaymentDTO(debitAccountId, creditAccountId, taxAmount)); - } - } - - if (!isAccountTransfer) { - isAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), - PortfolioAccountType.SAVINGS); - } - final SavingsTransactionDTO transaction = new SavingsTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, amount, reversed, feePayments, penaltyPayments, overdraftAmount, isAccountTransfer, - taxPayments); - - newSavingsTransactions.add(transaction); - - } - - return new SavingsDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, accrualBasedAccountingEnabled, - newSavingsTransactions, depositAccountType); - } - - public SharesDTO populateSharesDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, - final boolean accrualBasedAccountingEnabled) { - final Long shareAccountId = (Long) accountingBridgeData.get("shareAccountId"); - final Long shareProductId = (Long) accountingBridgeData.get("shareProductId"); - final Long officeId = (Long) accountingBridgeData.get("officeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final List newTransactions = new ArrayList<>(); - - @SuppressWarnings("unchecked") - final List> newTransactionsMap = (List>) accountingBridgeData.get("newTransactions"); - - for (final Map map : newTransactionsMap) { - final Long transactionOfficeId = (Long) map.get("officeId"); - final String transactionId = ((Long) map.get("id")).toString(); - final LocalDate transactionDate = ((LocalDate) map.get("date")); - final ShareAccountTransactionEnumData transactionType = (ShareAccountTransactionEnumData) map.get("type"); - final ShareAccountTransactionEnumData transactionStatus = (ShareAccountTransactionEnumData) map.get("status"); - final BigDecimal amount = (BigDecimal) map.get("amount"); - final BigDecimal chargeAmount = (BigDecimal) map.get("chargeAmount"); - final Long paymentTypeId = (Long) map.get("paymentTypeId"); - - final List feePayments = new ArrayList<>(); - // extract charge payment details (if exists) - if (map.containsKey("chargesPaid")) { - @SuppressWarnings("unchecked") - final List> chargesPaidData = (List>) map.get("chargesPaid"); - for (final Map chargePaid : chargesPaidData) { - final Long chargeId = (Long) chargePaid.get("chargeId"); - final Long loanChargeId = (Long) chargePaid.get("sharesChargeId"); - final BigDecimal chargeAmountPaid = (BigDecimal) chargePaid.get("amount"); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - feePayments.add(chargePaymentDTO); - } - } - final SharesTransactionDTO transaction = new SharesTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, transactionStatus, amount, chargeAmount, feePayments); - - newTransactions.add(transaction); - - } - - return new SharesDTO(shareAccountId, shareProductId, officeId, currencyCode, cashBasedAccountingEnabled, - accrualBasedAccountingEnabled, newTransactions); - } - - public ClientTransactionDTO populateClientTransactionDtoFromMap(final Map accountingBridgeData) { - - final Long transactionOfficeId = (Long) accountingBridgeData.get("officeId"); - final Long clientId = (Long) accountingBridgeData.get("clientId"); - final Long transactionId = (Long) accountingBridgeData.get("id"); - final LocalDate transactionDate = ((LocalDate) accountingBridgeData.get("date")); - final EnumOptionData transactionType = (EnumOptionData) accountingBridgeData.get("type"); - final BigDecimal amount = (BigDecimal) accountingBridgeData.get("amount"); - final boolean reversed = (Boolean) accountingBridgeData.get("reversed"); - final Long paymentTypeId = (Long) accountingBridgeData.get("paymentTypeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final Boolean accountingEnabled = (Boolean) accountingBridgeData.get("accountingEnabled"); - - final List clientChargePaymentDTOs = new ArrayList<>(); - // extract client charge payment details (if exists) - if (accountingBridgeData.containsKey("clientChargesPaid")) { - @SuppressWarnings("unchecked") - final List> clientChargesPaidData = (List>) accountingBridgeData - .get("clientChargesPaid"); - for (final Map clientChargePaid : clientChargesPaidData) { - final Long chargeId = (Long) clientChargePaid.get("chargeId"); - final Long clientChargeId = (Long) clientChargePaid.get("clientChargeId"); - final boolean isPenalty = (Boolean) clientChargePaid.get("isPenalty"); - final BigDecimal chargeAmountPaid = (BigDecimal) clientChargePaid.get("amount"); - final Long incomeAccountId = (Long) clientChargePaid.get("incomeAccountId"); - final ClientChargePaymentDTO clientChargePaymentDTO = new ClientChargePaymentDTO(chargeId, chargeAmountPaid, clientChargeId, - isPenalty, incomeAccountId); - clientChargePaymentDTOs.add(clientChargePaymentDTO); - } - } - - return new ClientTransactionDTO(clientId, transactionOfficeId, paymentTypeId, transactionId, transactionDate, transactionType, - currencyCode, amount, reversed, accountingEnabled, clientChargePaymentDTOs); - - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Accrual Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * office - * @param currencyCode - * currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param loanProductId - * loanProductId - * @param loanId - * loanId - * @param transactionId - * transactionId - * @param transactionDate - * transactionDate - * @param totalAmount - * totalAmount - * @param chargePaymentDTOs - * chargePaymentDTOs - */ - public void createJournalEntriesForLoanCharges(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final Integer accountTypeToBeCredited, final Long loanProductId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { - - final Map creditDetailsMap = new LinkedHashMap<>(); - final Map debitDetailsMap = new LinkedHashMap<>(); - - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final Long chargeId = chargePaymentDTO.getChargeId(); - final GLAccount chargeSpecificCreditAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeCredited, - chargeId); - final GLAccount chargeSpecificDebitAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeDebited, chargeId); - final BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // aggregate amounts by account for credit entries - creditDetailsMap.merge(chargeSpecificCreditAccount, chargeSpecificAmount, BigDecimal::add); - - // aggregate amounts by account for debit entries - debitDetailsMap.merge(chargeSpecificDebitAccount, chargeSpecificAmount, BigDecimal::add); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - BigDecimal totalDebitedAmount = BigDecimal.ZERO; - - // Create credit journal entries - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - // Create debit journal entries using charge-specific debit accounts - for (final Map.Entry entry : debitDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalDebitedAmount = totalDebitedAmount.add(amount); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charge credits does not equal the total transaction amount", - "Sum of charge credits (" + totalCreditedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " - + loanId + ", transaction " + transactionId, - totalCreditedAmount, totalAmount); - } - - if (totalAmount.compareTo(totalDebitedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charge debits does not equal the total transaction amount", - "Sum of charge debits (" + totalDebitedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " - + loanId + ", transaction " + transactionId, - totalDebitedAmount, totalAmount); - } - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * @param paymentTypeId - * @param loanId - * @param transactionId - * @param transactionDate - * @param amount - * @param isReversal - */ - public void createCashBasedJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Integer accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - int accountTypeToDebitId = accountTypeToBeDebited; - int accountTypeToCreditId = accountTypeToBeCredited; - // reverse debits and credits for reversals - if (isReversal) { - accountTypeToDebitId = accountTypeToBeCredited; - accountTypeToCreditId = accountTypeToBeDebited; - } - createJournalEntriesForSavings(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, savingsProductId, paymentTypeId, - loanId, transactionId, transactionDate, amount); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param loanProductId - * @param paymentTypeId - * @param loanId - * @param transactionId - * @param transactionDate - * @param amount - */ - public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final Integer accountTypeToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - int accountTypeToDebitId = accountTypeToBeDebited; - int accountTypeToCreditId = accountTypeToBeCredited; - createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, loanProductId, paymentTypeId, loanId, - transactionId, transactionDate, amount); - } - - public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final GLAccount accountToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - int accountTypeToDebitId = accountTypeToBeDebited; - createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountToBeCredited, loanProductId, paymentTypeId, loanId, - transactionId, transactionDate, amount); - } - - public void createSplitJournalEntriesForLoan(Office office, String currencyCode, List splitAccountsHolder, - JournalAmountHolder totalAccountHolder, Long loanProductId, Long paymentTypeId, Long loanId, String transactionId, - LocalDate transactionDate) { - splitAccountsHolder.forEach(journalItemHolder -> { - if (MathUtil.isGreaterThanZero(journalItemHolder.getAmount())) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, journalItemHolder.getAccountType(), - paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, - journalItemHolder.getAmount()); - } - }); - if (MathUtil.isGreaterThanZero(totalAccountHolder.getAmount())) { - final GLAccount totalAccount = getLinkedGLAccountForLoanProduct(loanProductId, totalAccountHolder.getAccountType(), - paymentTypeId); - createCreditJournalEntryForLoan(office, currencyCode, totalAccount, loanId, transactionId, transactionDate, - totalAccountHolder.getAmount()); - } - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, - final CashAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final int accountMappingTypeId = accountMappingType.getValue(); - createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, - transactionDate, amount); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, - final AccrualAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final int accountMappingTypeId = accountMappingType.getValue(); - createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, - transactionDate, amount); - } - - /** - * @param latestGLClosure - * @param transactionDate - */ - public void checkForBranchClosures(final GLClosure latestGLClosure, final LocalDate transactionDate) { - // check if an accounting closure has happened for this branch after the transaction Date - if (latestGLClosure != null) { - if (!DateUtils.isBefore(latestGLClosure.getClosingDate(), transactionDate)) { - throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.ACCOUNTING_CLOSED, latestGLClosure.getClosingDate(), - null, null); - } - } - } - - public GLClosure getLatestClosureByBranch(final long officeId) { - return this.closureRepository.getLatestGLClosureByBranch(officeId); - } - - private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); - final GLAccount creditAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToCreditId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); - createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); - } - - private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, - final GLAccount creditAccount, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); - createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); - } - - private void createJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - - /** - * Interest credited to a member, routed through the payable and paid accounts the product names. - * - *

-     *   Dr  Interest On Savings        (expense)     the bank incurs the cost
-     *   Cr  Savings Interest Payable   (liability)   and now owes it
-     *
-     *   Dr  Savings Interest Payable
-     *   Cr  Savings Interest Paid                    the debt is on its way out
-     *
-     *   Dr  Savings Interest Paid
-     *   Cr  Savings Control                          and reaches the member
-     * 
- * - *

- * The two middle accounts net to nil across the three steps, so the trial balance is unchanged - * from the plain two-legged entry; what they add is a reading of how much interest has been - * declared but not yet credited, which a single Dr/Cr pair cannot show. - * - *

- * Both are read from the product's Accounting tab and neither is required. A product that names - * no paid account collapses the middle step, and one that names no payable falls back to its own - * Savings Control - either way the posting degrades to exactly the two-legged entry it was - * before, so products configured before these mappings existed are unaffected. - * - *

- * Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings} - * does for the two-legged case. - */ - public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) { - - final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); - - // The payable comes from the Interest Payable row on the product's Accounting tab, which is - // where a bank actually picks it. The reserved name in the product's other GL mappings is - // consulted only after that, for a product configured before that row was mapped, and the - // Savings Control last of all - interest owed is a liability either way. - final GLAccount payableAccount = Optional - .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, - AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) - .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING))) - .orElse(controlAccount); - final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) - .orElse(payableAccount); - - // The interest expense is not raised here. It is recognised as the interest accrues, day by - // day, and this posting settles that recognised debt - raising the expense again at payout - // would put the same interest on the P&L twice. This mirrors the pooled posting service, - // which pays a product's whole membership the same way; a closure reaching this path must - // produce the same entries as the nightly run, or the two disagree on what one payment did. - // - // Stage 1: the liability moves from the payable to the account it is paid out of. Where the - // product names no paid account both resolve to the payable and this says nothing, so it is - // skipped and the payout comes straight out of the payable. - final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); - if (!payableIsPaid) { - postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, - isReversal); - } - - // Stage 2: into the member's own balance. - final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; - postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, - isReversal); - } - - /** - * One Dr/Cr pair, with both sides swapped when the transaction is a reversal. - * - *

- * A leg whose two sides are the same account writes nothing. That is not a defensive check but - * the mechanism by which the three stages above collapse: a product that names no payable and no - * paid account resolves all three to its Savings Control, and what should remain is the plain - * {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping - * the middle stage by hand was not enough — stage 3 still resolved to Control against Control and - * wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's - * debits and credits unequal per account and shows up on the trial balance as movement that never - * happened. Deciding it here rather than at each call site means every collapse works the same - * way, whichever of the two names a product happens to leave unset. - */ - private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount, - final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final boolean isReversal) { - if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) { - return; - } - final GLAccount toDebit = isReversal ? creditAccount : debitAccount; - final GLAccount toCredit = isReversal ? debitAccount : creditAccount; - createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount); - createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount); - } - - /** - * A GL account the savings product maps under one of the reserved names on its Accounting tab. - * - *

- * These live in {@code m_savings_product.other_gl_mappings} as a JSON document of - * {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in - * {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's - * fixed financial account types. The name is matched across all four groups: which group a bank - * files it under is a presentation choice, and an account configured under the wrong heading - * should still be honoured rather than silently ignored. - * - * @return the account, or null where the product does not configure this name - */ - private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { - if (savingsProductId == null) { - return null; - } - // The column holds an empty string on a product saved with no named mappings, and casting - // that to jsonb is an error rather than an empty document - so the cast is done inside a - // subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE - // clause in any order, so guarding at the same query level as the cast would only work by - // luck. - final List found = jdbcTemplate.queryForList(""" - SELECT gl.id - FROM (SELECT other_gl_mappings::jsonb AS doc - FROM m_savings_product - WHERE id = ? - AND other_gl_mappings IS NOT NULL - AND other_gl_mappings <> '' - AND other_gl_mappings LIKE '{%') sp - CROSS JOIN LATERAL jsonb_array_elements( - COALESCE(sp.doc -> 'assets', '[]'::jsonb) - || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) - || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) - || COALESCE(sp.doc -> 'income', '[]'::jsonb) - ) AS m(entry) - JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' - WHERE m.entry ->> 'accountName' = ? - LIMIT 1 - """, Long.class, savingsProductId, accountName); - return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); - } - - /** - * Creates a direct transfer journal entry between two savings-type accounts (savings or current), - * each with their own product GL mapping and entity ID so the correct account number appears per entry. - * - * @param debitProductId product ID whose SAVINGS_CONTROL GL is debited - * @param debitEntityId entity (account) ID shown on the debit entry - * @param creditProductId product ID whose SAVINGS_CONTROL GL is credited - * @param creditEntityId entity (account) ID shown on the credit entry - */ - public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode, - final Long debitProductId, final Long debitEntityId, - final Long creditProductId, final Long creditEntityId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * @param paymentTypeId - * @param savingsId - * @param transactionId - * @param transactionDate - * @param amount - * @param isReversal - * @param taxDetails - */ - public void createCashBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, - final CashAccountsForSavings accountTypeToBeDebited, final CashAccountsForSavings accountTypeToBeCredited, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal, final List taxDetails) { - - for (TaxPaymentDTO taxPaymentDTO : taxDetails) { - if (taxPaymentDTO.getAmount() != null) { - if (taxPaymentDTO.getCreditAccountId() == null) { - createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } else { - createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, taxPaymentDTO.getCreditAccountId(), - savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), isReversal); - } - } - } - createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), savingsProductId, - paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); - } - - public void createAccrualBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal, final List taxDetails) { - for (TaxPaymentDTO taxPaymentDTO : taxDetails) { - if (taxPaymentDTO.getAmount() != null) { - if (taxPaymentDTO.getCreditAccountId() == null) { - createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } else { - createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, - taxPaymentDTO.getCreditAccountId(), savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } - } - } - createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); - } - - public void createCashBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - final GLAccount creditAccount = getGLAccountById(creditAccountId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - final GLAccount creditAccount = getGLAccountById(creditAccountId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - } - - private void createDebitJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); - } - - private void createCreditJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToCreditId, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long chargeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createCreditJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs) { - createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, - transactionDate, totalAmount, chargePaymentDTOs, true); - } - - public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs) { - createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, - transactionDate, totalAmount, chargePaymentDTOs, false); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * office - * @param currencyCode - * currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * savingsProductId - * @param paymentTypeId - * paymentTypeId - * @param loanId - * loanId - * @param transactionId - * transactionId - * @param transactionDate - * transactionDate - * @param totalAmount - * totalAmount - * @param isReversal - * isReversal - * @param chargePaymentDTOs - * chargePaymentDTOs - */ - public void createCashBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, - final CashAccountsForSavings accountTypeToBeDebited, CashAccountsForSavings accountTypeToBeCredited, - final Long savingsProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final Boolean isReversal, - final List chargePaymentDTOs) { - // TODO Vishwas: Remove this validation, as and when appropriate Junit - // tests are written for accounting - /** - * Accounting module currently supports a single charge per transaction, throw an error if this is not the case - * here so any developers changing the expected portfolio behavior would also take care of modifying the - * accounting code appropriately - **/ - if (chargePaymentDTOs.size() != 1) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); - } - ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); - GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - - final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), - paymentTypeId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - } else { - createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - } - } - - public void createAccrualBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final Boolean isReversal, final List chargePaymentDTOs) { - // TODO Vishwas: Remove this validation, as and when appropriate Junit - // tests are written for accounting - /** - * Accounting module currently supports a single charge per transaction, throw an error if this is not the case - * here so any developers changing the expected portfolio behavior would also take care of modifying the - * accounting code appropriately - **/ - if (chargePaymentDTOs.size() != 1) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); - } - ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); - GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - - final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), - paymentTypeId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - } else { - createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - } - } - - public Office getOfficeById(final long officeId) { - return this.officeRepository.getReferenceById(officeId); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); - createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, amount, account); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - private void createCreditJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - - String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, - transactionId, null); - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, - final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) - throws DataAccessException { - final boolean manualEntry = false; - Long savingsAccountTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - savingsAccountTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, - savingsAccountTransactionId, null, null); - - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long loanTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - loanTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, - loanTransactionId, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createProvisioningDebitJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, String currencyCode, - GLAccount account, BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, - null, null, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createProvisioningCreditJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, - String currencyCode, GLAccount account, BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, - null, null, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long loanTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - loanTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, - loanTransactionId, null, null, null); - persistJournalEntry(journalEntry); - } - - private void createDebitJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, - final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long savingsAccountTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - savingsAccountTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, - savingsAccountTransactionId, null, null); - - persistJournalEntry(journalEntry); - } - - private void createDebitJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, - transactionId, null); - persistJournalEntry(journalEntry); - } - - public void createJournalEntriesForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - createDebitJournalEntryForShares(office, currencyCode, accountTypeToDebitId, shareProductId, paymentTypeId, shareAccountId, - transactionId, transactionDate, amount); - createCreditJournalEntryForShares(office, currencyCode, accountTypeToCreditId, shareProductId, paymentTypeId, shareAccountId, - transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForShares(office, currencyCode, debitAccount, shareAccountId, transactionId, transactionDate, amount); - } - - public void createCreditJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToCreditId, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount creditAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToCreditId, paymentTypeId); - createCreditJournalEntryForShares(office, currencyCode, creditAccount, shareAccountId, transactionId, transactionDate, amount); - } - - public void createCashBasedJournalEntriesForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeDebited, final CashAccountsForShares accountTypeToBeCredited, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { - - createDebitJournalEntryForShares(office, currencyCode, accountTypeToBeDebited.getValue(), shareProductId, paymentTypeId, - shareAccountId, transactionId, transactionDate, totalAmount); - createCashBasedJournalEntryForSharesCharges(office, currencyCode, accountTypeToBeCredited, shareProductId, shareAccountId, - transactionId, transactionDate, totalAmount, chargePaymentDTOs); - } - - public void createCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, - final List chargePaymentDTOs) { - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in the - // map - if (creditDetailsMap.containsKey(chargeSpecificAccount)) { - final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createCreditJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); - } - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); - } - } - - public void revertCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, - final List chargePaymentDTOs) { - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in the - // map - if (creditDetailsMap.containsKey(chargeSpecificAccount)) { - final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createDebitJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); - } - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); - } - } - - private void createDebitJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, - final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long shareTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - shareTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, - null, null, shareTransactionId); - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, - final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long shareTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - shareTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, - null, null, shareTransactionId); - persistJournalEntry(journalEntry); - } - - public GLAccount getLinkedGLAccountForLoanProduct(final Long loanProductId, final int accountMappingTypeId, final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId); - - /**** - * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source - * placeholder ID would be same for both cash and accrual accounts - ***/ - if (accountMappingTypeId == CashAccountsForLoan.FUND_SOURCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.LOAN, loanProductId, - AccrualAccountsForLoan.fromInt(accountMappingTypeId).toString()); - - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForLoanCharges(final Long loanProductId, final int accountMappingTypeId, final Long chargeId) { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - // Check for charge-specific mappings for all account types (not just income accounts) - // This allows charge-specific GL account mappings for debit accounts as well - if (chargeId != null) { - final ProductToGLAccountMapping chargeSpecificAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(loanProductId, PortfolioProductType.LOAN.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificAccountMapping != null) { - accountMapping = chargeSpecificAccountMapping; - } - } - return accountMapping.getGlAccount(); - } - - private GLAccount getLinkedGLAccountForSavingsCharges(final Long savingsProductId, final int accountMappingTypeId, - final Long chargeId) { - - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - // Vishwas TODO: remove this condition as it should always be true - - if (accountMappingTypeId == CashAccountsForSavings.INCOME_FROM_FEES.getValue() - || accountMappingTypeId == CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()) { - GLAccount glAccount = chargeRepositoryWrapper.findOneWithNotFoundDetection(chargeId).getAccount(); - if (glAccount != null) { - return glAccount; - } - final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(savingsProductId, PortfolioProductType.SAVING.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificIncomeAccountMapping != null) { - - accountMapping = chargeSpecificIncomeAccountMapping; - } - } - - return accountMapping.getGlAccount(); - } - - /** - * One row of the savings product's Accounting tab, or null where the bank has not filled it in. - * - *

- * Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is - * required. Interest Payable is offered on the product screen but a product configured before it - * existed has none, and that has to fall through to the next choice rather than stopping the - * account from being closed. - */ - private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, - final Long paymentTypeId) { - if (savingsProductId == null) { - return null; - } - // Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a - // missing mapping that method builds its message through CashAccountsForSavings.fromInt, - // which returns null for a row the cash enum does not carry - Interest Payable among them - - // and the lookup would die on a NullPointerException instead of the exception meant for it. - final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - return mapping == null ? null : mapping.getGlAccount(); - } - - private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, - final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - /**** - * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source - * placeholder ID would be same for both cash and accrual accounts - ***/ - if (accountMappingTypeId == CashAccountsForSavings.SAVINGS_REFERENCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId, - CashAccountsForSavings.fromInt(accountMappingTypeId).toString()); - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForShareProduct(final Long shareProductId, final int accountMappingTypeId, - final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId); - - if (accountMappingTypeId == CashAccountsForShares.SHARES_REFERENCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - // Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can - // now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so - // this has to fail with a clean, named error instead of NPEing on accountMapping. - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId, - CashAccountsForShares.fromInt(accountMappingTypeId).toString()); - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForShareCharges(final Long shareProductId, final int accountMappingTypeId, final Long chargeId) { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(shareProductId, PortfolioProductType.SHARES.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificIncomeAccountMapping != null) { - accountMapping = chargeSpecificIncomeAccountMapping; - } - return accountMapping.getGlAccount(); - } - - private boolean isOrganizationAccount(final int accountMappingTypeId) { - return FinancialActivity.fromInt(accountMappingTypeId) != null; - } - - public BigDecimal createCreditJournalEntryOrReversalForClientPayments(final Office office, final String currencyCode, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final Boolean isReversal, - final List clientChargePaymentDTOs) { - /*** - * Map to track each account affected and the net credit to be made for a particular account - ***/ - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ClientChargePaymentDTO clientChargePaymentDTO : clientChargePaymentDTOs) { - if (clientChargePaymentDTO.getIncomeAccountId() != null) { - final Long accountId = clientChargePaymentDTO.getIncomeAccountId(); - BigDecimal chargeSpecificAmount = clientChargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in - // the map - if (creditDetailsMap.containsKey(accountId)) { - final BigDecimal existingAmount = creditDetailsMap.get(accountId); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(accountId, chargeSpecificAmount); - } - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = getGLAccountById(entry.getKey()); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - if (isReversal) { - createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } - } - return totalCreditedAmount; - } - - public void createDebitJournalEntryOrReversalForClientChargePayments(final Office office, final String currencyCode, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount, - final Boolean isReversal) { - final GLAccount account = financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(FinancialActivity.ASSET_FUND_SOURCE.getValue()).getGlAccount(); - if (isReversal) { - createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } else { - createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } - } - - private GLAccount getGLAccountById(final Long accountId) { - return this.glAccountRepository.getReferenceById(accountId); - } - - public Integer getValueForFeeOrPenaltyIncomeAccount(final String chargeRefundChargeType) { - if (chargeRefundChargeType == null - || !(chargeRefundChargeType.equalsIgnoreCase("P") || chargeRefundChargeType.equalsIgnoreCase("F"))) { - String errorValue; - errorValue = Objects.requireNonNullElse(chargeRefundChargeType, "Null"); - throw new PlatformDataIntegrityException("error.msg.chargeRefundChargeType.can.only.be.P.or.F", - "chargeRefundChargeType can only be P (Penalty) or F(Fee) - Value is: " + errorValue); - } - Integer incomeAccount; - if (chargeRefundChargeType.equalsIgnoreCase("P")) { - incomeAccount = AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(); - } else { - incomeAccount = AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(); - - } - return incomeAccount; - } - - public JournalEntry persistJournalEntry(JournalEntry journalEntry) { - boolean isNew = journalEntry.isNew(); - JournalEntry savedJournalEntry = this.glJournalEntryRepository.saveAndFlush(journalEntry); - if (isNew && journalEntry.getLoanTransactionId() != null) { - businessEventNotifierService.notifyPostBusinessEvent(new LoanJournalEntryCreatedBusinessEvent(savedJournalEntry)); - } - return savedJournalEntry; - } - - private void createJournalEntriesForLoanChargesInternal(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs, final boolean isCredit) { - final Map creditDetailsMap = new LinkedHashMap<>(); - - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final Long chargeId = chargePaymentDTO.getChargeId(); - final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); - BigDecimal amount = chargePaymentDTO.getAmount(); - - creditDetailsMap.merge(account, amount, BigDecimal::add); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - - for (Map.Entry entry : creditDetailsMap.entrySet()) { - GLAccount account = entry.getKey(); - BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - - if (isCredit) { - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } else { - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - } - - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", - "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", - totalCreditedAmount, totalAmount); - } - } -} +/** + * 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.accounting.journalentry.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.accounting.closure.domain.GLClosure; +import org.apache.fineract.accounting.closure.domain.GLClosureRepository; +import org.apache.fineract.accounting.common.AccountingConstants; +import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan; +import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; +import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.LoanDTO; +import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.SavingsDTO; +import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.SharesDTO; +import org.apache.fineract.accounting.journalentry.data.SharesTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.TaxPaymentDTO; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException.GlJournalEntryInvalidReason; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.event.business.domain.journalentry.LoanJournalEntryCreatedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepository; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.account.PortfolioAccountType; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; +import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; +import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; +import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; +import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +@RequiredArgsConstructor +public class AccountingProcessorHelper { + + public static final String LOAN_TRANSACTION_IDENTIFIER = "L"; + public static final String SAVINGS_TRANSACTION_IDENTIFIER = "S"; + public static final String FIXED_DEPOSIT_TRANSACTION_IDENTIFIER = "FD"; + public static final String RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER = "RD"; + public static final String CLIENT_TRANSACTION_IDENTIFIER = "C"; + public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; + public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; + + /** + * Reserved names on the savings product's Accounting tab, naming the two accounts an interest + * posting is routed through. The names are the contract with the product screen; the GL codes + * behind them are the bank's choice, so no code is named here. + */ + public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable"; + public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid"; + + private static final ThreadLocal DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); + + public static void setDepositAccountTypeContext(int depositAccountType) { + DEPOSIT_ACCOUNT_TYPE_CONTEXT.set(depositAccountType); + } + + public static void clearDepositAccountTypeContext() { + DEPOSIT_ACCOUNT_TYPE_CONTEXT.remove(); + } + + private String getSavingsTransactionIdentifier() { + return switch (DEPOSIT_ACCOUNT_TYPE_CONTEXT.get()) { + case 200 -> FIXED_DEPOSIT_TRANSACTION_IDENTIFIER; + case 300 -> RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER; + default -> SAVINGS_TRANSACTION_IDENTIFIER; + }; + } + + private final JournalEntryRepository glJournalEntryRepository; + private final ProductToGLAccountMappingRepository accountMappingRepository; + private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; + private final GLClosureRepository closureRepository; + private final GLAccountRepository glAccountRepository; + private final OfficeRepository officeRepository; + private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; + private final ChargeRepositoryWrapper chargeRepositoryWrapper; + private final BusinessEventNotifierService businessEventNotifierService; + // The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a + // JSON document rather than in acc_product_mapping, so they are read directly. See + // namedSavingsGlAccount. + private final JdbcTemplate jdbcTemplate; + + public LoanDTO populateLoanDtoFromDTO( + final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { + final Long loanId = accountingBridgeData.getLoanId(); + final Long loanProductId = accountingBridgeData.getLoanProductId(); + final Long officeId = accountingBridgeData.getOfficeId(); + final String currencyCode = accountingBridgeData.getCurrencyCode(); + final List newLoanTransactions = new ArrayList<>(); + boolean isAccountTransfer = accountingBridgeData.isAccountTransfer(); + boolean isLoanMarkedAsChargeOff = accountingBridgeData.isChargeOff(); + boolean isLoanMarkedAsFraud = accountingBridgeData.isFraud(); + final Long chargeOffReasonCodeValue = accountingBridgeData.getChargeOffReasonCodeValue(); + final boolean isLoanMarkedAsWrittenOff = accountingBridgeData.isWrittenOff(); + final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled(); + final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled(); + final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled(); + final boolean merchantBuyDownFee = accountingBridgeData.isMerchantBuyDownFee(); + + final List loanTransactionDTOs = accountingBridgeData.getNewLoanTransactions(); + + for (final AccountingBridgeLoanTransactionDTO loanTxnDto : loanTransactionDTOs) { + final Long transactionOfficeId = loanTxnDto.getOfficeId(); + final String transactionId = loanTxnDto.getId().toString(); + final LocalDate transactionDate = loanTxnDto.getDate(); + final LoanTransactionEnumData transactionType = loanTxnDto.getType(); + final BigDecimal amount = loanTxnDto.getAmount(); + final BigDecimal principal = loanTxnDto.getPrincipalPortion(); + final BigDecimal interest = loanTxnDto.getInterestPortion(); + final BigDecimal fees = loanTxnDto.getFeeChargesPortion(); + final BigDecimal penalties = loanTxnDto.getPenaltyChargesPortion(); + final BigDecimal overPayments = loanTxnDto.getOverPaymentPortion(); + final boolean reversed = loanTxnDto.isReversed(); + final Long paymentTypeId = loanTxnDto.getPaymentTypeId(); + final String chargeRefundChargeType = loanTxnDto.getChargeRefundChargeType(); + final LoanChargeData loanChargeData = loanTxnDto.getLoanChargeData(); + + final List feePaymentDetails = new ArrayList<>(); + final List penaltyPaymentDetails = new ArrayList<>(); + // extract charge payment details (if exists) + if (loanTxnDto.getLoanChargesPaid() != null) { + List loanChargesPaidData = loanTxnDto.getLoanChargesPaid(); + for (final LoanChargePaidByDTO loanChargePaid : loanChargesPaidData) { + final Long chargeId = loanChargePaid.getChargeId(); + final Long loanChargeId = loanChargePaid.getLoanChargeId(); + final boolean isPenalty = loanChargePaid.getIsPenalty(); + final BigDecimal chargeAmountPaid = loanChargePaid.getAmount(); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + if (isPenalty) { + penaltyPaymentDetails.add(chargePaymentDTO); + } else { + feePaymentDetails.add(chargePaymentDTO); + } + } + } + + boolean localIsAccountTransfer = isAccountTransfer; + if (!localIsAccountTransfer) { + localIsAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), + PortfolioAccountType.LOAN); + } + + BigDecimal principalPaid = loanTxnDto.getPrincipalPaid(); + BigDecimal feePaid = loanTxnDto.getFeePaid(); + BigDecimal penaltyPaid = loanTxnDto.getPenaltyPaid(); + + final LoanTransactionDTO transaction = new LoanTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, amount, principal, interest, fees, penalties, overPayments, reversed, + penaltyPaymentDetails, feePaymentDetails, localIsAccountTransfer, chargeRefundChargeType, loanChargeData, principalPaid, + feePaid, penaltyPaid); + + transaction.setLoanToLoanTransfer(loanTxnDto.isLoanToLoanTransfer()); + newLoanTransactions.add(transaction); + } + + return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled, + periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud, + chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff, merchantBuyDownFee, + accountingBridgeData.getBuydownFeeClassificationCodeValue(), + accountingBridgeData.getCapitalizedIncomeClassificationCodeValue(), accountingBridgeData.getWriteOffReasonCodeValue()); + } + + public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + Long chargeOffReasonId) { + return accountMappingRepository.findChargeOffReasonMapping(loanProductId, productType.getValue(), chargeOffReasonId); + } + + public ProductToGLAccountMapping getWriteOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + Long writeOffReasonId) { + return accountMappingRepository.findWriteOffReasonMapping(loanProductId, productType.getValue(), writeOffReasonId); + } + + public ProductToGLAccountMapping getClassificationMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + final Long classificationId, final String classificationType) { + if (LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue().equals(classificationType)) { + return accountMappingRepository.findBuydownFeeClassificationMapping(loanProductId, productType.getValue(), classificationId); + } else { + return accountMappingRepository.findCapitalizedIncomeClassificationMapping(loanProductId, productType.getValue(), + classificationId); + } + } + + public SavingsDTO populateSavingsDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, + final boolean accrualBasedAccountingEnabled) { + final Long loanId = (Long) accountingBridgeData.get("savingsId"); + final Long loanProductId = (Long) accountingBridgeData.get("savingsProductId"); + final Long officeId = (Long) accountingBridgeData.get("officeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final int depositAccountType = accountingBridgeData.containsKey("depositAccountType") + ? (Integer) accountingBridgeData.get("depositAccountType") + : 100; + final List newSavingsTransactions = new ArrayList<>(); + boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer"); + + @SuppressWarnings("unchecked") + final List> newTransactionsMap = (List>) accountingBridgeData.get("newSavingsTransactions"); + + for (final Map map : newTransactionsMap) { + final Long transactionOfficeId = (Long) map.get("officeId"); + final String transactionId = ((Long) map.get("id")).toString(); + final LocalDate transactionDate = ((LocalDate) map.get("date")); + final SavingsAccountTransactionEnumData transactionType = (SavingsAccountTransactionEnumData) map.get("type"); + final BigDecimal amount = (BigDecimal) map.get("amount"); + final boolean reversed = (Boolean) map.get("reversed"); + final Long paymentTypeId = (Long) map.get("paymentTypeId"); + final BigDecimal overdraftAmount = (BigDecimal) map.get("overdraftAmount"); + + final List feePayments = new ArrayList<>(); + final List penaltyPayments = new ArrayList<>(); + // extract charge payment details (if exists) + if (map.containsKey("savingsChargesPaid")) { + @SuppressWarnings("unchecked") + final List> savingsChargesPaidData = (List>) map.get("savingsChargesPaid"); + for (final Map loanChargePaid : savingsChargesPaidData) { + final Long chargeId = (Long) loanChargePaid.get("chargeId"); + final Long loanChargeId = (Long) loanChargePaid.get("savingsChargeId"); + final boolean isPenalty = (Boolean) loanChargePaid.get("isPenalty"); + final BigDecimal chargeAmountPaid = (BigDecimal) loanChargePaid.get("amount"); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + if (isPenalty) { + penaltyPayments.add(chargePaymentDTO); + } else { + feePayments.add(chargePaymentDTO); + } + } + } + + final List taxPayments = new ArrayList<>(); + if (map.containsKey("taxDetails")) { + @SuppressWarnings("unchecked") + final List> taxDataList = (List>) map.get("taxDetails"); + for (final Map taxData : taxDataList) { + final BigDecimal taxAmount = (BigDecimal) taxData.get("amount"); + final Long creditAccountId = (Long) taxData.get("creditAccountId"); + final Long debitAccountId = (Long) taxData.get("debitAccountId"); + taxPayments.add(new TaxPaymentDTO(debitAccountId, creditAccountId, taxAmount)); + } + } + + if (!isAccountTransfer) { + isAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), + PortfolioAccountType.SAVINGS); + } + final SavingsTransactionDTO transaction = new SavingsTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, amount, reversed, feePayments, penaltyPayments, overdraftAmount, isAccountTransfer, + taxPayments); + + newSavingsTransactions.add(transaction); + + } + + return new SavingsDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, accrualBasedAccountingEnabled, + newSavingsTransactions, depositAccountType); + } + + public SharesDTO populateSharesDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, + final boolean accrualBasedAccountingEnabled) { + final Long shareAccountId = (Long) accountingBridgeData.get("shareAccountId"); + final Long shareProductId = (Long) accountingBridgeData.get("shareProductId"); + final Long officeId = (Long) accountingBridgeData.get("officeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final List newTransactions = new ArrayList<>(); + + @SuppressWarnings("unchecked") + final List> newTransactionsMap = (List>) accountingBridgeData.get("newTransactions"); + + for (final Map map : newTransactionsMap) { + final Long transactionOfficeId = (Long) map.get("officeId"); + final String transactionId = ((Long) map.get("id")).toString(); + final LocalDate transactionDate = ((LocalDate) map.get("date")); + final ShareAccountTransactionEnumData transactionType = (ShareAccountTransactionEnumData) map.get("type"); + final ShareAccountTransactionEnumData transactionStatus = (ShareAccountTransactionEnumData) map.get("status"); + final BigDecimal amount = (BigDecimal) map.get("amount"); + final BigDecimal chargeAmount = (BigDecimal) map.get("chargeAmount"); + final Long paymentTypeId = (Long) map.get("paymentTypeId"); + + final List feePayments = new ArrayList<>(); + // extract charge payment details (if exists) + if (map.containsKey("chargesPaid")) { + @SuppressWarnings("unchecked") + final List> chargesPaidData = (List>) map.get("chargesPaid"); + for (final Map chargePaid : chargesPaidData) { + final Long chargeId = (Long) chargePaid.get("chargeId"); + final Long loanChargeId = (Long) chargePaid.get("sharesChargeId"); + final BigDecimal chargeAmountPaid = (BigDecimal) chargePaid.get("amount"); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + feePayments.add(chargePaymentDTO); + } + } + final SharesTransactionDTO transaction = new SharesTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, transactionStatus, amount, chargeAmount, feePayments); + + newTransactions.add(transaction); + + } + + return new SharesDTO(shareAccountId, shareProductId, officeId, currencyCode, cashBasedAccountingEnabled, + accrualBasedAccountingEnabled, newTransactions); + } + + public ClientTransactionDTO populateClientTransactionDtoFromMap(final Map accountingBridgeData) { + + final Long transactionOfficeId = (Long) accountingBridgeData.get("officeId"); + final Long clientId = (Long) accountingBridgeData.get("clientId"); + final Long transactionId = (Long) accountingBridgeData.get("id"); + final LocalDate transactionDate = ((LocalDate) accountingBridgeData.get("date")); + final EnumOptionData transactionType = (EnumOptionData) accountingBridgeData.get("type"); + final BigDecimal amount = (BigDecimal) accountingBridgeData.get("amount"); + final boolean reversed = (Boolean) accountingBridgeData.get("reversed"); + final Long paymentTypeId = (Long) accountingBridgeData.get("paymentTypeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final Boolean accountingEnabled = (Boolean) accountingBridgeData.get("accountingEnabled"); + + final List clientChargePaymentDTOs = new ArrayList<>(); + // extract client charge payment details (if exists) + if (accountingBridgeData.containsKey("clientChargesPaid")) { + @SuppressWarnings("unchecked") + final List> clientChargesPaidData = (List>) accountingBridgeData + .get("clientChargesPaid"); + for (final Map clientChargePaid : clientChargesPaidData) { + final Long chargeId = (Long) clientChargePaid.get("chargeId"); + final Long clientChargeId = (Long) clientChargePaid.get("clientChargeId"); + final boolean isPenalty = (Boolean) clientChargePaid.get("isPenalty"); + final BigDecimal chargeAmountPaid = (BigDecimal) clientChargePaid.get("amount"); + final Long incomeAccountId = (Long) clientChargePaid.get("incomeAccountId"); + final ClientChargePaymentDTO clientChargePaymentDTO = new ClientChargePaymentDTO(chargeId, chargeAmountPaid, clientChargeId, + isPenalty, incomeAccountId); + clientChargePaymentDTOs.add(clientChargePaymentDTO); + } + } + + return new ClientTransactionDTO(clientId, transactionOfficeId, paymentTypeId, transactionId, transactionDate, transactionType, + currencyCode, amount, reversed, accountingEnabled, clientChargePaymentDTOs); + + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Accrual Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * office + * @param currencyCode + * currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param loanProductId + * loanProductId + * @param loanId + * loanId + * @param transactionId + * transactionId + * @param transactionDate + * transactionDate + * @param totalAmount + * totalAmount + * @param chargePaymentDTOs + * chargePaymentDTOs + */ + public void createJournalEntriesForLoanCharges(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final Integer accountTypeToBeCredited, final Long loanProductId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { + + final Map creditDetailsMap = new LinkedHashMap<>(); + final Map debitDetailsMap = new LinkedHashMap<>(); + + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final Long chargeId = chargePaymentDTO.getChargeId(); + final GLAccount chargeSpecificCreditAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeCredited, + chargeId); + final GLAccount chargeSpecificDebitAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeDebited, chargeId); + final BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // aggregate amounts by account for credit entries + creditDetailsMap.merge(chargeSpecificCreditAccount, chargeSpecificAmount, BigDecimal::add); + + // aggregate amounts by account for debit entries + debitDetailsMap.merge(chargeSpecificDebitAccount, chargeSpecificAmount, BigDecimal::add); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + BigDecimal totalDebitedAmount = BigDecimal.ZERO; + + // Create credit journal entries + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + // Create debit journal entries using charge-specific debit accounts + for (final Map.Entry entry : debitDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalDebitedAmount = totalDebitedAmount.add(amount); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charge credits does not equal the total transaction amount", + "Sum of charge credits (" + totalCreditedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " + + loanId + ", transaction " + transactionId, + totalCreditedAmount, totalAmount); + } + + if (totalAmount.compareTo(totalDebitedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charge debits does not equal the total transaction amount", + "Sum of charge debits (" + totalDebitedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " + + loanId + ", transaction " + transactionId, + totalDebitedAmount, totalAmount); + } + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * @param paymentTypeId + * @param loanId + * @param transactionId + * @param transactionDate + * @param amount + * @param isReversal + */ + public void createCashBasedJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Integer accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + int accountTypeToDebitId = accountTypeToBeDebited; + int accountTypeToCreditId = accountTypeToBeCredited; + // reverse debits and credits for reversals + if (isReversal) { + accountTypeToDebitId = accountTypeToBeCredited; + accountTypeToCreditId = accountTypeToBeDebited; + } + createJournalEntriesForSavings(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, savingsProductId, paymentTypeId, + loanId, transactionId, transactionDate, amount); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param loanProductId + * @param paymentTypeId + * @param loanId + * @param transactionId + * @param transactionDate + * @param amount + */ + public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final Integer accountTypeToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + int accountTypeToDebitId = accountTypeToBeDebited; + int accountTypeToCreditId = accountTypeToBeCredited; + createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, loanProductId, paymentTypeId, loanId, + transactionId, transactionDate, amount); + } + + public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final GLAccount accountToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + int accountTypeToDebitId = accountTypeToBeDebited; + createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountToBeCredited, loanProductId, paymentTypeId, loanId, + transactionId, transactionDate, amount); + } + + public void createSplitJournalEntriesForLoan(Office office, String currencyCode, List splitAccountsHolder, + JournalAmountHolder totalAccountHolder, Long loanProductId, Long paymentTypeId, Long loanId, String transactionId, + LocalDate transactionDate) { + splitAccountsHolder.forEach(journalItemHolder -> { + if (MathUtil.isGreaterThanZero(journalItemHolder.getAmount())) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, journalItemHolder.getAccountType(), + paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, + journalItemHolder.getAmount()); + } + }); + if (MathUtil.isGreaterThanZero(totalAccountHolder.getAmount())) { + final GLAccount totalAccount = getLinkedGLAccountForLoanProduct(loanProductId, totalAccountHolder.getAccountType(), + paymentTypeId); + createCreditJournalEntryForLoan(office, currencyCode, totalAccount, loanId, transactionId, transactionDate, + totalAccountHolder.getAmount()); + } + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, + final CashAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final int accountMappingTypeId = accountMappingType.getValue(); + createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, + transactionDate, amount); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, + final AccrualAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final int accountMappingTypeId = accountMappingType.getValue(); + createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, + transactionDate, amount); + } + + /** + * @param latestGLClosure + * @param transactionDate + */ + public void checkForBranchClosures(final GLClosure latestGLClosure, final LocalDate transactionDate) { + // check if an accounting closure has happened for this branch after the transaction Date + if (latestGLClosure != null) { + if (!DateUtils.isBefore(latestGLClosure.getClosingDate(), transactionDate)) { + throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.ACCOUNTING_CLOSED, latestGLClosure.getClosingDate(), + null, null); + } + } + } + + public GLClosure getLatestClosureByBranch(final long officeId) { + return this.closureRepository.getLatestGLClosureByBranch(officeId); + } + + private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); + final GLAccount creditAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToCreditId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); + createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); + } + + private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, + final GLAccount creditAccount, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); + createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); + } + + private void createJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + + /** + * Interest credited to a member, routed through the payable and paid accounts the product names. + * + *

+     *   Dr  Interest On Savings        (expense)     the bank incurs the cost
+     *   Cr  Savings Interest Payable   (liability)   and now owes it
+     *
+     *   Dr  Savings Interest Payable
+     *   Cr  Savings Interest Paid                    the debt is on its way out
+     *
+     *   Dr  Savings Interest Paid
+     *   Cr  Savings Control                          and reaches the member
+     * 
+ * + *

+ * The two middle accounts net to nil across the three steps, so the trial balance is unchanged + * from the plain two-legged entry; what they add is a reading of how much interest has been + * declared but not yet credited, which a single Dr/Cr pair cannot show. + * + *

+ * Both are read from the product's Accounting tab and neither is required. A product that names + * no paid account collapses the middle step, and one that names no payable falls back to its own + * Savings Control - either way the posting degrades to exactly the two-legged entry it was + * before, so products configured before these mappings existed are unaffected. + * + *

+ * Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings} + * does for the two-legged case. + */ + public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) { + + final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + + // The payable comes from the Interest Payable row on the product's Accounting tab, which is + // where a bank actually picks it. The reserved name in the product's other GL mappings is + // consulted only after that, for a product configured before that row was mapped, and the + // Savings Control last of all - interest owed is a liability either way. + final GLAccount payableAccount = Optional + .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, + AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) + .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING))) + .orElse(controlAccount); + final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) + .orElse(payableAccount); + + // The interest expense is not raised here. It is recognised as the interest accrues, day by + // day, and this posting settles that recognised debt - raising the expense again at payout + // would put the same interest on the P&L twice. This mirrors the pooled posting service, + // which pays a product's whole membership the same way; a closure reaching this path must + // produce the same entries as the nightly run, or the two disagree on what one payment did. + // + // Stage 1: the liability moves from the payable to the account it is paid out of. Where the + // product names no paid account both resolve to the payable and this says nothing, so it is + // skipped and the payout comes straight out of the payable. + final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); + if (!payableIsPaid) { + postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + } + + // Stage 2: into the member's own balance. + final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; + postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + } + + /** + * One Dr/Cr pair, with both sides swapped when the transaction is a reversal. + * + *

+ * A leg whose two sides are the same account writes nothing. That is not a defensive check but + * the mechanism by which the three stages above collapse: a product that names no payable and no + * paid account resolves all three to its Savings Control, and what should remain is the plain + * {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping + * the middle stage by hand was not enough — stage 3 still resolved to Control against Control and + * wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's + * debits and credits unequal per account and shows up on the trial balance as movement that never + * happened. Deciding it here rather than at each call site means every collapse works the same + * way, whichever of the two names a product happens to leave unset. + */ + private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount, + final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final boolean isReversal) { + if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) { + return; + } + final GLAccount toDebit = isReversal ? creditAccount : debitAccount; + final GLAccount toCredit = isReversal ? debitAccount : creditAccount; + createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount); + } + + /** + * A GL account the savings product maps under one of the reserved names on its Accounting tab. + * + *

+ * These live in {@code m_savings_product.other_gl_mappings} as a JSON document of + * {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in + * {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's + * fixed financial account types. The name is matched across all four groups: which group a bank + * files it under is a presentation choice, and an account configured under the wrong heading + * should still be honoured rather than silently ignored. + * + * @return the account, or null where the product does not configure this name + */ + private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { + if (savingsProductId == null) { + return null; + } + // The column holds an empty string on a product saved with no named mappings, and casting + // that to jsonb is an error rather than an empty document - so the cast is done inside a + // subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE + // clause in any order, so guarding at the same query level as the cast would only work by + // luck. + final List found = jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE m.entry ->> 'accountName' = ? + LIMIT 1 + """, Long.class, savingsProductId, accountName); + return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); + } + + /** + * Creates a direct transfer journal entry between two savings-type accounts (savings or current), + * each with their own product GL mapping and entity ID so the correct account number appears per entry. + * + * @param debitProductId product ID whose SAVINGS_CONTROL GL is debited + * @param debitEntityId entity (account) ID shown on the debit entry + * @param creditProductId product ID whose SAVINGS_CONTROL GL is credited + * @param creditEntityId entity (account) ID shown on the credit entry + */ + public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode, + final Long debitProductId, final Long debitEntityId, + final Long creditProductId, final Long creditEntityId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); + } + + /** + * The same direct transfer where either side may be a current account. A current product maps its GLs under + * {@link PortfolioProductType#CURRENT}, so each side's control GL is read under its own product type -- reading a + * current product under SAVING picked up the GLs of the savings product that happens to share its id. + */ + public void createDirectTransferJournalEntries(final Office office, final String currencyCode, final Long debitProductId, + final boolean debitIsCurrent, final Long debitEntityId, final Long creditProductId, final boolean creditIsCurrent, + final Long creditEntityId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = controlGlAccount(debitProductId, debitIsCurrent); + final GLAccount creditAccount = controlGlAccount(creditProductId, creditIsCurrent); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); + } + + /** Dr a current product's Current Control, Cr the GL mapped to {@code financialActivity}. */ + public void createCurrentControlToFinancialActivityJournalEntries(final Office office, final String currencyCode, + final Long currentProductId, final int financialActivity, final Long currentAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount controlAccount = controlGlAccount(currentProductId, true); + final GLAccount activityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(financialActivity).getGlAccount(); + createDebitJournalEntryForSavings(office, currencyCode, controlAccount, currentAccountId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, activityAccount, currentAccountId, transactionId, transactionDate, amount); + } + + private GLAccount controlGlAccount(final Long productId, final boolean isCurrent) { + if (!isCurrent) { + return getLinkedGLAccountForSavingsProduct(productId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + } + final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(productId, + PortfolioProductType.CURRENT.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + if (mapping == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.product.control.gl.not.mapped", + "Current product " + productId + " has no Current Control GL mapped; map it on the product's Accounting tab.", + productId); + } + return mapping.getGlAccount(); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * @param paymentTypeId + * @param savingsId + * @param transactionId + * @param transactionDate + * @param amount + * @param isReversal + * @param taxDetails + */ + public void createCashBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, + final CashAccountsForSavings accountTypeToBeDebited, final CashAccountsForSavings accountTypeToBeCredited, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal, final List taxDetails) { + + for (TaxPaymentDTO taxPaymentDTO : taxDetails) { + if (taxPaymentDTO.getAmount() != null) { + if (taxPaymentDTO.getCreditAccountId() == null) { + createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } else { + createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, taxPaymentDTO.getCreditAccountId(), + savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), isReversal); + } + } + } + createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), savingsProductId, + paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } + + public void createAccrualBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal, final List taxDetails) { + for (TaxPaymentDTO taxPaymentDTO : taxDetails) { + if (taxPaymentDTO.getAmount() != null) { + if (taxPaymentDTO.getCreditAccountId() == null) { + createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } else { + createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, + taxPaymentDTO.getCreditAccountId(), savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } + } + } + createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } + + public void createCashBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + final GLAccount creditAccount = getGLAccountById(creditAccountId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + final GLAccount creditAccount = getGLAccountById(creditAccountId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + } + + private void createDebitJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); + } + + private void createCreditJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToCreditId, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long chargeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createCreditJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs) { + createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, + transactionDate, totalAmount, chargePaymentDTOs, true); + } + + public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs) { + createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, + transactionDate, totalAmount, chargePaymentDTOs, false); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * office + * @param currencyCode + * currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * savingsProductId + * @param paymentTypeId + * paymentTypeId + * @param loanId + * loanId + * @param transactionId + * transactionId + * @param transactionDate + * transactionDate + * @param totalAmount + * totalAmount + * @param isReversal + * isReversal + * @param chargePaymentDTOs + * chargePaymentDTOs + */ + public void createCashBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, + final CashAccountsForSavings accountTypeToBeDebited, CashAccountsForSavings accountTypeToBeCredited, + final Long savingsProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final Boolean isReversal, + final List chargePaymentDTOs) { + // TODO Vishwas: Remove this validation, as and when appropriate Junit + // tests are written for accounting + /** + * Accounting module currently supports a single charge per transaction, throw an error if this is not the case + * here so any developers changing the expected portfolio behavior would also take care of modifying the + * accounting code appropriately + **/ + if (chargePaymentDTOs.size() != 1) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); + } + ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); + GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + + final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), + paymentTypeId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + } else { + createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + } + } + + public void createAccrualBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final Boolean isReversal, final List chargePaymentDTOs) { + // TODO Vishwas: Remove this validation, as and when appropriate Junit + // tests are written for accounting + /** + * Accounting module currently supports a single charge per transaction, throw an error if this is not the case + * here so any developers changing the expected portfolio behavior would also take care of modifying the + * accounting code appropriately + **/ + if (chargePaymentDTOs.size() != 1) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); + } + ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); + GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + + final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), + paymentTypeId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + } else { + createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + } + } + + public Office getOfficeById(final long officeId) { + return this.officeRepository.getReferenceById(officeId); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); + createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, amount, account); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + private void createCreditJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + + String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, + transactionId, null); + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, + final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) + throws DataAccessException { + final boolean manualEntry = false; + Long savingsAccountTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + savingsAccountTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, + savingsAccountTransactionId, null, null); + + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long loanTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + loanTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, + loanTransactionId, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createProvisioningDebitJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, String currencyCode, + GLAccount account, BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, + null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createProvisioningCreditJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, + String currencyCode, GLAccount account, BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, + null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long loanTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + loanTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, + loanTransactionId, null, null, null); + persistJournalEntry(journalEntry); + } + + private void createDebitJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, + final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long savingsAccountTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + savingsAccountTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, + savingsAccountTransactionId, null, null); + + persistJournalEntry(journalEntry); + } + + private void createDebitJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, + transactionId, null); + persistJournalEntry(journalEntry); + } + + public void createJournalEntriesForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + createDebitJournalEntryForShares(office, currencyCode, accountTypeToDebitId, shareProductId, paymentTypeId, shareAccountId, + transactionId, transactionDate, amount); + createCreditJournalEntryForShares(office, currencyCode, accountTypeToCreditId, shareProductId, paymentTypeId, shareAccountId, + transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForShares(office, currencyCode, debitAccount, shareAccountId, transactionId, transactionDate, amount); + } + + public void createCreditJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToCreditId, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount creditAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToCreditId, paymentTypeId); + createCreditJournalEntryForShares(office, currencyCode, creditAccount, shareAccountId, transactionId, transactionDate, amount); + } + + public void createCashBasedJournalEntriesForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeDebited, final CashAccountsForShares accountTypeToBeCredited, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { + + createDebitJournalEntryForShares(office, currencyCode, accountTypeToBeDebited.getValue(), shareProductId, paymentTypeId, + shareAccountId, transactionId, transactionDate, totalAmount); + createCashBasedJournalEntryForSharesCharges(office, currencyCode, accountTypeToBeCredited, shareProductId, shareAccountId, + transactionId, transactionDate, totalAmount, chargePaymentDTOs); + } + + public void createCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, + final List chargePaymentDTOs) { + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in the + // map + if (creditDetailsMap.containsKey(chargeSpecificAccount)) { + final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createCreditJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); + } + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); + } + } + + public void revertCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, + final List chargePaymentDTOs) { + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in the + // map + if (creditDetailsMap.containsKey(chargeSpecificAccount)) { + final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createDebitJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); + } + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); + } + } + + private void createDebitJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, + final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long shareTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + shareTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, + null, null, shareTransactionId); + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, + final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long shareTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + shareTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, + null, null, shareTransactionId); + persistJournalEntry(journalEntry); + } + + public GLAccount getLinkedGLAccountForLoanProduct(final Long loanProductId, final int accountMappingTypeId, final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId); + + /**** + * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source + * placeholder ID would be same for both cash and accrual accounts + ***/ + if (accountMappingTypeId == CashAccountsForLoan.FUND_SOURCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.LOAN, loanProductId, + AccrualAccountsForLoan.fromInt(accountMappingTypeId).toString()); + + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForLoanCharges(final Long loanProductId, final int accountMappingTypeId, final Long chargeId) { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + // Check for charge-specific mappings for all account types (not just income accounts) + // This allows charge-specific GL account mappings for debit accounts as well + if (chargeId != null) { + final ProductToGLAccountMapping chargeSpecificAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(loanProductId, PortfolioProductType.LOAN.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificAccountMapping != null) { + accountMapping = chargeSpecificAccountMapping; + } + } + return accountMapping.getGlAccount(); + } + + private GLAccount getLinkedGLAccountForSavingsCharges(final Long savingsProductId, final int accountMappingTypeId, + final Long chargeId) { + + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + // Vishwas TODO: remove this condition as it should always be true + + if (accountMappingTypeId == CashAccountsForSavings.INCOME_FROM_FEES.getValue() + || accountMappingTypeId == CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()) { + GLAccount glAccount = chargeRepositoryWrapper.findOneWithNotFoundDetection(chargeId).getAccount(); + if (glAccount != null) { + return glAccount; + } + final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(savingsProductId, PortfolioProductType.SAVING.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificIncomeAccountMapping != null) { + + accountMapping = chargeSpecificIncomeAccountMapping; + } + } + + return accountMapping.getGlAccount(); + } + + /** + * One row of the savings product's Accounting tab, or null where the bank has not filled it in. + * + *

+ * Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is + * required. Interest Payable is offered on the product screen but a product configured before it + * existed has none, and that has to fall through to the next choice rather than stopping the + * account from being closed. + */ + private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + if (savingsProductId == null) { + return null; + } + // Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a + // missing mapping that method builds its message through CashAccountsForSavings.fromInt, + // which returns null for a row the cash enum does not carry - Interest Payable among them - + // and the lookup would die on a NullPointerException instead of the exception meant for it. + final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + return mapping == null ? null : mapping.getGlAccount(); + } + + private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + /**** + * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source + * placeholder ID would be same for both cash and accrual accounts + ***/ + if (accountMappingTypeId == CashAccountsForSavings.SAVINGS_REFERENCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId, + CashAccountsForSavings.fromInt(accountMappingTypeId).toString()); + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForShareProduct(final Long shareProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId); + + if (accountMappingTypeId == CashAccountsForShares.SHARES_REFERENCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + // Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can + // now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so + // this has to fail with a clean, named error instead of NPEing on accountMapping. + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId, + CashAccountsForShares.fromInt(accountMappingTypeId).toString()); + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForShareCharges(final Long shareProductId, final int accountMappingTypeId, final Long chargeId) { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(shareProductId, PortfolioProductType.SHARES.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificIncomeAccountMapping != null) { + accountMapping = chargeSpecificIncomeAccountMapping; + } + return accountMapping.getGlAccount(); + } + + private boolean isOrganizationAccount(final int accountMappingTypeId) { + return FinancialActivity.fromInt(accountMappingTypeId) != null; + } + + public BigDecimal createCreditJournalEntryOrReversalForClientPayments(final Office office, final String currencyCode, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final Boolean isReversal, + final List clientChargePaymentDTOs) { + /*** + * Map to track each account affected and the net credit to be made for a particular account + ***/ + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ClientChargePaymentDTO clientChargePaymentDTO : clientChargePaymentDTOs) { + if (clientChargePaymentDTO.getIncomeAccountId() != null) { + final Long accountId = clientChargePaymentDTO.getIncomeAccountId(); + BigDecimal chargeSpecificAmount = clientChargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in + // the map + if (creditDetailsMap.containsKey(accountId)) { + final BigDecimal existingAmount = creditDetailsMap.get(accountId); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(accountId, chargeSpecificAmount); + } + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = getGLAccountById(entry.getKey()); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + if (isReversal) { + createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } + } + return totalCreditedAmount; + } + + public void createDebitJournalEntryOrReversalForClientChargePayments(final Office office, final String currencyCode, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount, + final Boolean isReversal) { + final GLAccount account = financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(FinancialActivity.ASSET_FUND_SOURCE.getValue()).getGlAccount(); + if (isReversal) { + createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } else { + createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } + } + + private GLAccount getGLAccountById(final Long accountId) { + return this.glAccountRepository.getReferenceById(accountId); + } + + public Integer getValueForFeeOrPenaltyIncomeAccount(final String chargeRefundChargeType) { + if (chargeRefundChargeType == null + || !(chargeRefundChargeType.equalsIgnoreCase("P") || chargeRefundChargeType.equalsIgnoreCase("F"))) { + String errorValue; + errorValue = Objects.requireNonNullElse(chargeRefundChargeType, "Null"); + throw new PlatformDataIntegrityException("error.msg.chargeRefundChargeType.can.only.be.P.or.F", + "chargeRefundChargeType can only be P (Penalty) or F(Fee) - Value is: " + errorValue); + } + Integer incomeAccount; + if (chargeRefundChargeType.equalsIgnoreCase("P")) { + incomeAccount = AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(); + } else { + incomeAccount = AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(); + + } + return incomeAccount; + } + + public JournalEntry persistJournalEntry(JournalEntry journalEntry) { + boolean isNew = journalEntry.isNew(); + JournalEntry savedJournalEntry = this.glJournalEntryRepository.saveAndFlush(journalEntry); + if (isNew && journalEntry.getLoanTransactionId() != null) { + businessEventNotifierService.notifyPostBusinessEvent(new LoanJournalEntryCreatedBusinessEvent(savedJournalEntry)); + } + return savedJournalEntry; + } + + private void createJournalEntriesForLoanChargesInternal(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs, final boolean isCredit) { + final Map creditDetailsMap = new LinkedHashMap<>(); + + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final Long chargeId = chargePaymentDTO.getChargeId(); + final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); + BigDecimal amount = chargePaymentDTO.getAmount(); + + creditDetailsMap.merge(account, amount, BigDecimal::add); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + + for (Map.Entry entry : creditDetailsMap.entrySet()) { + GLAccount account = entry.getKey(); + BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + + if (isCredit) { + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } else { + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + } + + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", + "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", + totalCreditedAmount, totalAmount); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java new file mode 100644 index 000000000..f47c5b129 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java @@ -0,0 +1,152 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.account.service; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +/** + * A book debit: money leaves a savings or current account by book entry, not as cash. + * + *

+ * Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...). + * Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry -- + * cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no + * report shows. + * + *

+ * It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance + * (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's + * transaction, so a later failure rolls the debit back with everything else. + */ +@Service +@RequiredArgsConstructor +public class AccountBookDebitService { + + private final JdbcTemplate jdbcTemplate; + private final KycRestrictionGuard kycRestrictionGuard; + + /** Debits a savings account; returns the m_savings_account_transaction id. */ + public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate, + final Long userId) { + final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount); + kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT); + + final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction " + + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, " + + "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, " + + "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, accountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setDate(5, sqlDate); + ps.setString(6, refNo()); + ps.setBigDecimal(7, balance.subtract(amount)); + ps.setLong(8, userId); + ps.setLong(9, userId); + ps.setObject(10, now); + ps.setObject(11, now); + return ps; + }, kh); + jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, " + + "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId); + return Objects.requireNonNull(kh.getKey()).longValue(); + } + + /** Debits a current account; returns the m_current_account_transaction id. */ + public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate, + final Long userId, final String note) { + final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount); + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT); + + final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); + final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note); + final KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction " + + "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, " + + "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) " + + "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" }); + ps.setLong(1, accountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setString(5, refNo()); + ps.setDate(6, sqlDate); + ps.setBigDecimal(7, balance.subtract(amount)); + ps.setString(8, remarks); + ps.setLong(9, userId); + return ps; + }, kh); + jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, " + + "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId); + return Objects.requireNonNull(kh.getKey()).longValue(); + } + + /** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */ + private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList( + "SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId); + if (rows.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.", + accountId); + } + final int status = ((Number) rows.get(0).get("status_enum")).intValue(); + if (status != 300) { + throw new GeneralPlatformDomainRuleException("error.msg.account.not.active", + kind + " account " + accountId + " is not active.", accountId); + } + final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO + : (BigDecimal) rows.get(0).get("account_balance_derived"); + final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO + : (BigDecimal) rows.get(0).get("on_hold_funds_derived"); + final BigDecimal available = balance.subtract(onHold); + if (available.compareTo(amount) < 0) { + throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance", + "Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available + + ", Requested: " + amount, + accountId); + } + return balance; + } + + private static String refNo() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 20); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 1ae645c56..172e39de7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction; import org.apache.fineract.portfolio.account.domain.AccountTransferType; import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; @@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer private final CurrentAccountRepository currentAccountRepository; private final CurrentAccountTransactionRepository currentAccountTransactionRepository; private final AccountingProcessorHelper accountingProcessorHelper; + private final KycRestrictionGuard kycRestrictionGuard; @Transactional @Override @@ -472,7 +474,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - toSavingsAccount.productId(), toSavingsAccount.getId()); + toSavingsAccount.productId(), false, toSavingsAccount.getId()); // skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts // DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number. @@ -517,7 +519,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - fromSavingsAccount.productId(), fromSavingsAccount.getId()); + fromSavingsAccount.productId(), false, fromSavingsAccount.getId()); if (accountTransferDetails == null) { accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer( @@ -545,10 +547,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - toCurrentAccount.getProductId(), toCurrentAccount.getId()); + toCurrentAccount.getProductId(), true, toCurrentAccount.getId()); performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - fromCurrentAccount.getProductId(), fromCurrentAccount.getId()); + fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId()); if (accountTransferDetails == null) { accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer( @@ -681,6 +683,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer */ private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount, final LocalDate txnDate) { + // Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is. + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; final BigDecimal available = balance.subtract(onHold); @@ -699,10 +703,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer currentAccountRepository.save(account); if (account.getProductId() != null && txn.getId() != null) { final String currentTxnId = "CA" + txn.getId(); - accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(), - account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), - FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null, - account.getId(), currentTxnId, txnDate, amount, false); + accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(), + account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(), + account.getId(), currentTxnId, txnDate, amount); } return txn; } @@ -712,7 +715,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient. */ private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) { + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final Long counterpartEntityId) { + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; final BigDecimal available = balance.subtract(onHold); @@ -730,7 +735,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId); + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent, + counterpartEntityId); return txn; } @@ -738,7 +744,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Deposit amount into a current account and save the transaction. */ private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) { + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final Long counterpartEntityId) { + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null); final BigDecimal newBalance = balance.add(amount); @@ -749,7 +757,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId); + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent, + counterpartEntityId); return txn; } @@ -783,7 +792,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer */ private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn, final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal, - final Long counterpartProductId, final Long counterpartEntityId) { + final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) { if (account.getProductId() == null || txn.getId() == null) { return; } @@ -794,17 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final String currentTxnId = "CA" + txn.getId(); if (isWithdrawal) { // DR: current account GL CR: counterpart (savings/current) GL - accountingProcessorHelper.createDirectTransferJournalEntriesForSavings( - account.getOffice(), account.getCurrencyCode(), - account.getProductId(), account.getId(), - counterpartProductId, counterpartEntityId, + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId, currentTxnId, txnDate, amount); } else { // DR: counterpart (savings/current) GL CR: current account GL - accountingProcessorHelper.createDirectTransferJournalEntriesForSavings( - account.getOffice(), account.getCurrencyCode(), - counterpartProductId, counterpartEntityId, - account.getProductId(), account.getId(), + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(), currentTxnId, txnDate, amount); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java index 58be11f63..4c3d4bcfa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java @@ -1,140 +1,141 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.account.starter; - -import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; -import org.apache.fineract.infrastructure.core.config.FineractProperties; -import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; -import org.apache.fineract.infrastructure.core.service.PaginationHelper; -import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; -import org.apache.fineract.infrastructure.security.service.SqlValidator; -import org.apache.fineract.infrastructure.security.utils.ColumnValidator; -import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; -import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; -import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator; -import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler; -import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; -import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; -import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler; -import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; -import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper; -import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; -import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; -import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService; -import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl; -import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; -import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService; -import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; -import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; -import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; -import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; -import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.JdbcTemplate; - -@Configuration -public class AccountConfiguration { - - @Bean - @ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class) - public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) { - return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate); - } - - @Bean - @ConditionalOnMissingBean(AccountTransfersReadPlatformService.class) - public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate, - ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, - PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper, - SqlValidator sqlValidator) { - return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, - portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator); - } - - @Bean - @ConditionalOnMissingBean(AccountTransfersWritePlatformService.class) - public AccountTransfersWritePlatformService accountTransfersWritePlatformService( - AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler, - AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler, - SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler, - LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService, - AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, - GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, - FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, - CurrentAccountTransactionRepository currentAccountTransactionRepository, - AccountingProcessorHelper accountingProcessorHelper) { - return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, - accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, - loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, - gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, - currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper); - } - - @Bean - @ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class) - public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate, - DatabaseSpecificSQLGenerator sqlGenerator) { - return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class) - public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate, - ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { - return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionReadPlatformService.class) - public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate, - ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, - PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, - DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { - return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, - portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionWritePlatformService.class) - public StandingInstructionWritePlatformService standingInstructionWritePlatformService( - StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler, - AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) { - return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler, - accountTransferDetailRepository, standingInstructionRepository); - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.account.starter; + +import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.SqlValidator; +import org.apache.fineract.infrastructure.security.utils.ColumnValidator; +import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; +import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator; +import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler; +import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; +import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; +import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler; +import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; +import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; +import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService; +import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl; +import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; +import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService; +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; +import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; +import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; +import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class AccountConfiguration { + + @Bean + @ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class) + public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) { + return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate); + } + + @Bean + @ConditionalOnMissingBean(AccountTransfersReadPlatformService.class) + public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate, + ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, + PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper, + SqlValidator sqlValidator) { + return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, + portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator); + } + + @Bean + @ConditionalOnMissingBean(AccountTransfersWritePlatformService.class) + public AccountTransfersWritePlatformService accountTransfersWritePlatformService( + AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler, + AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler, + SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler, + LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService, + AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, + GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, + FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, + CurrentAccountTransactionRepository currentAccountTransactionRepository, + AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) { + return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, + accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, + loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, + gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, + currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard); + } + + @Bean + @ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class) + public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate, + DatabaseSpecificSQLGenerator sqlGenerator) { + return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class) + public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate, + ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { + return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionReadPlatformService.class) + public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate, + ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, + PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, + DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { + return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, + portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionWritePlatformService.class) + public StandingInstructionWritePlatformService standingInstructionWritePlatformService( + StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler, + AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) { + return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler, + accountTransferDetailRepository, standingInstructionRepository); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index 5c128b357..66d0c6950 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -340,7 +340,7 @@ public class ChequeBookIssuanceChargeService { private Long getCurrentControlGlId(final Long currentAccountId) { final List> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java index 5fd98fe17..964c29498 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java @@ -50,7 +50,7 @@ public class ClearingGlAccountResolverService { if ("CURRENT".equalsIgnoreCase(accountType)) { return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId); + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", accountId); } return null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index e75955127..b8cc344d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -173,9 +173,16 @@ public class ClerkDisbursementRequestApiResource { // stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps // working, since nothing about a restricted customer should prevent them being paid. // A GL leg carries no savingsAccountId and the guard ignores it. + // A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against + // m_current_account -- checking it as a savings id would test some unrelated savings account. if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { - kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), - "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); + final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER + : KycRestrictionGuard.Operation.CREDIT; + if ("CURRENT".equals(entryAccountType)) { + kycRestrictionGuard.checkCurrentAccount(entry.get("savingsAccountId").getAsLong(), operation); + } else { + kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), operation); + } } // A Dr leg against a savings account is a withdrawal, and the clerk who raises it should // not learn the account can't pay out until a manager tries to approve it and the whole @@ -522,6 +529,7 @@ public class ClerkDisbursementRequestApiResource { // collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode). // disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode). final List collectionGlEntries = new ArrayList<>(); + final List collectionCreditGlEntries = new ArrayList<>(); final List disbursementGlEntries = new ArrayList<>(); // Compensation list: each successfully posted Fineract transaction gets an undo lambda. @@ -642,8 +650,9 @@ public class ClerkDisbursementRequestApiResource { // Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description); } else { - // Collection: CR GL is the target for the combined savings debit entry + // Collection: each CR GL takes its own amount in the one combined entry collectionTargetGlId = glId; + collectionCreditGlEntries.add(new GlEntry(glId, entryAmt, acNo, null)); } } txnRefs.add("GL:" + acNo); @@ -688,28 +697,26 @@ public class ClerkDisbursementRequestApiResource { undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId)); } } else { - try { - txnId = isCurrentAccount - ? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description) - : executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description); - } catch (IdempotentCommandProcessSucceedException idempotent) { - txnId = extractResourceIdFromResponse(idempotent.getResponse()); + // Collection: a direct book debit, exactly as a bank transfer debits. It used to run a + // cash withdrawal, whose own auto-entry (DR control / CR CashVault -- cash that never + // moved) was then hidden by flagging it reversed, and a current account's auto-entry was + // not even hidden, crediting CashVault instead of the target GL. Now nothing is posted + // here; the one combined entry below is the only GL posting. + final Long ctrlGl = isCurrentAccount ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); + if (ctrlGl == null) { + throw new RuntimeException("No control GL mapped for account " + acNo + + ". Map it on the account's product Accounting tab."); } - if (txnId != null) { - registerUndo(undoOps, accountId, txnId, isCurrentAccount); - txnRefs.add(txnId); - if (!isCurrentAccount) { - final String fullTxnId = "S" + txnId; - withdrawalSavingsTxnRefs.add(txnId); - withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); - // Savings withdrawal auto-creates: DR SavingsControl / CR CashVault. - // Collect the SavingsControl GL (DR side, type_enum=2) so we can - // post a direct DR SavingsControl / CR targetGL combined entry. - final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2); - if (savingsCtrlGl != null) { - collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId)); - } - } + final long debitTxnId = isCurrentAccount + ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) + : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); + txnRefs.add(String.valueOf(debitTxnId)); + withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); + collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + if (isCurrentAccount) { + undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId)); + } else { + undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, debitTxnId)); } } } @@ -732,39 +739,22 @@ public class ClerkDisbursementRequestApiResource { } // ── Collection GL entry ─────────────────────────────────────────────── - // Goal: DR SavingsControl(per account) / CR targetGL — one combined entry. - // Fineract withdrawals auto-create: DR SavingsControl / CR CashVault. - // We mark those auto-entries reversed (hidden from GL reports) and post - // the direct combined entry. Fallback: CashVault bridge if GL lookup fails. - if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) { - if (!collectionGlEntries.isEmpty() - && collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) { - markTxnGlEntriesReversed(collectionGlEntries); - final List snapC = new ArrayList<>(collectionGlEntries); - undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC)); - final String collGlTxnId = postCombinedJournalEntry( - collectionGlEntries, - List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)), - officeId, currencyCode, txnDateStr, - buildGlEntryComment("Collection", collectionGlEntries, description)); - // Link each DR entry to its savings account so ACCOUNT NO shows in the GL view. - if (collGlTxnId != null) { - linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); - } - approvedGlTxnId = collGlTxnId; - } else { - final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); - if (cashVaultGlId != null) { - approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, - officeId, currencyCode, txnDateStr, description); - } else if (!withdrawalSavingsTxnRefs.isEmpty()) { - // No bridging entry was needed: the withdrawal's own auto-posted credit - // leg already lands on the target GL directly (the savings product's own - // payment-type mapping points there). That withdrawal's own transaction is - // therefore the real posting -- there is no second one to remember instead. - approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0); - } + // One combined entry and nothing else: DR each account's own product control GL / CR each + // target GL. The accounts were debited directly above, so there is no auto-posted entry to + // hide and no CashVault leg to bridge. + if (!collectionGlEntries.isEmpty()) { + if (collectionCreditGlEntries.isEmpty()) { + throw new RuntimeException("Collection has no credit GL to receive " + withdrawalSavingsTotal + "."); } + final String collGlTxnId = postCombinedJournalEntry( + collectionGlEntries, collectionCreditGlEntries, + officeId, currencyCode, txnDateStr, + buildGlEntryComment("Collection", collectionGlEntries, description)); + // Link each DR entry to its savings/current account so ACCOUNT NO shows in the GL view. + if (collGlTxnId != null) { + linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); + } + approvedGlTxnId = collGlTxnId; } } // end else (disbursement/collection mode) @@ -1590,35 +1580,6 @@ public class ClerkDisbursementRequestApiResource { return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue(); } - /** - * Marks all Fineract auto-created journal entries for these transactions as reversed=true. - * Reversed entries are excluded from GL reports — only our direct combined entry is visible. - * Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails. - */ - private void markTxnGlEntriesReversed(final List entries) { - for (final GlEntry e : entries) { - if (e.txnId != null) { - // Guarded by reversed = false: if transaction_id text is ever reused, this can - // only flip rows that are currently live, never re-touch an already-reversed - // unrelated transaction's rows. - jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", - e.txnId); - } - } - } - - /** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */ - private void unmarkTxnGlEntriesReversed(final List entries) { - for (final GlEntry e : entries) { - if (e.txnId != null) { - jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true", - e.txnId); - } - } - } - /** * After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1) * row to its savings account so the GL view shows ACCOUNT NO instead of "—". @@ -1735,7 +1696,7 @@ public class ClerkDisbursementRequestApiResource { final List> rows = jdbcTemplate.queryForList( "SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java index ba568c32c..912af3e3e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java @@ -108,8 +108,20 @@ public class KycRestrictionGuard { checkKycExpiry(savingsAccountId, operation); } + /** + * The same two restrictions for a current account, read off the current account and its own current product + * (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's. + */ + public void checkCurrentAccount(final Long currentAccountId, final Operation operation) { + checkCurrentAccountDormancy(currentAccountId, operation); + enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation); + } + private void checkKycExpiry(final Long savingsAccountId, final Operation operation) { - final Long daysOverdue = daysOverdue(savingsAccountId); + enforceKycStage(daysOverdue(savingsAccountId), operation); + } + + private void enforceKycStage(final Long daysOverdue, final Operation operation) { if (daysOverdue == null) { return; } @@ -158,6 +170,28 @@ public class KycRestrictionGuard { JOIN m_savings_product sp ON sp.id = sa.product_id WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0 """, savingsAccountId); + refuseIfDormant(rows, operation, "savings"); + } + + private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) { + if (currentAccountId == null || operation == Operation.CREDIT) { + return; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT cp.days_to_dormancy, + (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') + - GREATEST(ca.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = ca.client_id AND k.verified_on IS NOT NULL), + ca.activatedon_date)) AS days_idle + FROM m_current_account ca + JOIN m_current_product cp ON cp.id = ca.product_id + WHERE ca.id = ? AND cp.is_dormancy_tracking_active = true AND cp.days_to_dormancy > 0 + """, currentAccountId); + refuseIfDormant(rows, operation, "current"); + } + + private void refuseIfDormant(final List> rows, final Operation operation, final String accountKind) { if (rows.isEmpty()) { return; } @@ -168,11 +202,30 @@ public class KycRestrictionGuard { } final long years = threshold.longValue() / 365; final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days"; - throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(), + throw new PlatformDataIntegrityException("error.msg." + accountKind + ".account.dormant." + operation.name().toLowerCase(), operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for " + period + ". It must be reactivated after the customer's identity is re-verified (re-KYC)."); } + private Long currentAccountDaysOverdue(final Long currentAccountId) { + if (currentAccountId == null) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue + FROM m_current_account ca + JOIN m_client_kyc k ON k.client_id = ca.client_id + WHERE ca.id = ? AND k.expiry_date IS NOT NULL + ORDER BY k.expiry_date DESC + LIMIT 1 + """, currentAccountId); + if (rows.isEmpty()) { + return null; + } + final Number daysOverdue = (Number) rows.get(0).get("days_overdue"); + return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue(); + } + /** * Days past the customer's re-KYC due date, or null where there is nothing overdue to measure. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java new file mode 100644 index 000000000..8e297fafb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java @@ -0,0 +1,850 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.ossreturn.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Live computation of the RBI off-site surveillance returns OSS 1 (RAL) and OSS 2 (RoE). + * + *

What these returns are

+ * + * OSS 1 is the bank's balance sheet restated on RBI's own line structure; OSS 2 is its profit and + * loss account on the same footing. Both are quarterly, both are denominated in thousands of + * rupees, and both are filed through RBI's CIMS portal. + * + *

Where every figure comes from

+ * + * Nothing here is a constant. Each return line names a role; the GL accounts pinned to that role + * decide the figure, and the ledger decides its value: + * + *
    + *
  • Balance-sheet roles (OSS 1) take the closing balance as at the quarter end - every + * journal entry on or before that date, on the account's own natural side.
  • + *
  • Earnings roles (OSS 2) take the movement over the quarter, because an income or + * expense line reports what was earned or spent in the period, not a balance carried forward.
  • + *
  • Counts (depositors, borrowers, members) come from the portfolio tables, since a + * headcount has no ledger balance to read.
  • + *
+ * + * The bank's own identity - name, RBI bank code, regional office, signatories - is read from + * {@code m_institution_profile}, the single place the institution's particulars are maintained. + * Nothing about the bank is written into this file. + * + *

Blank is not zero

+ * + * A role with no accounts pinned to it reports {@code null} and says so in + * {@code unmetRequirements}. It never reports 0.00. To RBI a nil figure is an assertion that the + * bank holds nothing under that head; an unmapped role is an assertion about nothing at all, and + * the two must not be filed as though they were the same statement. + */ +@Path("/v1/ossreturn") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class OssReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN"; + + /** Returns are denominated in thousands of rupees; the ledger is in rupees. */ + private static final BigDecimal RETURN_SCALE = BigDecimal.valueOf(1000); + + /** + * acc_gl_account.classification_enum. Only the debit-natured classifications are named, since + * they are what decides which way round a balance is read; everything else is credit-natured. + */ + private static final int ASSET = 1; + private static final int INCOME = 4; + private static final int EXPENSE = 5; + + /** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */ + private static final int POSTABLE_ACCOUNT = 1; + + /** + * Pseudo-role for the year's own result. Not a configurable role: it is derived from whatever + * income and expense accounts exist, so it cannot be left unmapped and cannot be mapped wrong. + */ + private static final String CURRENT_YEAR_RESULT = "__CURRENT_YEAR_RESULT__"; + + /** m_savings_account.status_enum for an active account. */ + private static final int SAVINGS_ACTIVE = 300; + + /** m_loan.loan_status_id for an active loan. */ + private static final int LOAN_ACTIVE = 300; + + /** + * m_savings_account.deposit_type_enum, per DepositAccountType. RBI's "Term Deposits" head + * covers both fixed and recurring deposits, so the two are counted together there. + */ + private static final int SAVINGS_DEPOSIT = 100; + private static final List TERM_DEPOSIT_TYPES = List.of(200, 300); + + /** How RBI prints dates on the General Information sheet. */ + private static final DateTimeFormatter RBI_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy", Locale.ENGLISH); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + // ─────────────────────────── OSS 1 — RAL ─────────────────────────── + + /** + * OSS 1 - Return on Assets and Liabilities, return code R070. + * + * @param quarterEndDateParam + * any date in the quarter being reported; the quarter end is derived from it, so a + * caller never has to know which day a quarter ends on. Defaults to the current + * business date. + */ + @GET + @Path("oss1") + public String oss1(@QueryParam("quarterEndDate") final String quarterEndDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(quarterEndDateParam); + final Quarter quarter = Quarter.containing(requested); + + final Map response = baseResponse("R070", "Return on Assets and Liabilities (RAL)", quarter, + officeId); + response.put("basis", Map.of("asOnDate", quarter.end.toString(), "note", + "Balance-sheet figures are the closing position as at " + quarter.end + + "; every journal entry on or before that date is included.")); + + // Liabilities and assets, in the order the RBI workbook prints them. Each line names the + // role whose mapped accounts produce it; a line with no role of its own is a subtotal and + // is summed from the lines it covers, never mapped separately. + final List> liabilities = new ArrayList<>(); + final RoleFigures figures = new RoleFigures(quarter, officeId); + + liabilities.add(balanceLine("1. Paid-up Capital", "OSS1_PAID_UP_CAPITAL", figures)); + liabilities.add(balanceLine("2. Reserve Fund and other Reserves", "OSS1_RESERVES", figures)); + liabilities.add(balanceLine("3. Profit (+) / Loss (-)", "OSS1_PROFIT_LOSS", figures)); + + // The year's own result, which the ledger has not appropriated to reserves yet. RBI prints + // it as its own head for exactly that reason: between appropriations the balance sheet is + // short by what the bank has earned or lost so far, and without this line the return would + // not balance even though the books do. Derived from the income and expense accounts rather + // than mapped, since it is not a balance any account carries. + final BigDecimal currentYearResult = currentYearResult(quarter, officeId); + liabilities.add(derivedLine( + currentYearResult.signum() >= 0 ? "3 iii. Operating Surplus in Current year" + : "3 iv. Operating Deficit in Current year", + CURRENT_YEAR_RESULT, currentYearResult)); + + liabilities.add(subtotal("4. Total Capital And Reserves", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES", + "OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT)); + liabilities.add(balanceLine("5. Subordinated Debt (Tier II Capital)", "OSS1_SUBORDINATED_DEBT", figures)); + liabilities.add(balanceLine("6. Deposits and other Accounts", "OSS1_DEPOSITS", figures)); + liabilities.add(countLine("Total No of Depositors", countDepositors(officeId, quarter.end, null))); + liabilities.add(balanceLine("i. Term Deposits (including recurring deposits)", "OSS1_DEPOSITS_TERM", figures)); + liabilities.add( + countLine("No. of Term Depositors", countDepositors(officeId, quarter.end, TERM_DEPOSIT_TYPES))); + liabilities.add(balanceLine("ii. Savings Bank Deposits", "OSS1_DEPOSITS_SAVINGS", figures)); + liabilities.add(countLine("No of Savings Depositors", + countDepositors(officeId, quarter.end, List.of(SAVINGS_DEPOSIT)))); + liabilities.add(balanceLine("iii. Current Deposits", "OSS1_DEPOSITS_CURRENT", figures)); + liabilities.add(balanceLine("7. Borrowings", "OSS1_BORROWINGS", figures)); + liabilities.add(balanceLine("8. Other Liabilities and Provisions", "OSS1_OTHER_LIABILITIES", figures)); + // The deposit sub-heads are a breakdown of OSS1_DEPOSITS, not additions to it, so the total + // names the head and leaves them out rather than counting the same money twice. + liabilities.add(subtotal("15. Total Liabilities", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES", + "OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT, "OSS1_SUBORDINATED_DEBT", "OSS1_DEPOSITS", "OSS1_BORROWINGS", + "OSS1_OTHER_LIABILITIES")); + + final List> assets = new ArrayList<>(); + assets.add(balanceLine("1. Cash in Hand", "OSS1_CASH_IN_HAND", figures)); + assets.add(balanceLine("2. Balances with Banks", "OSS1_BALANCE_WITH_BANKS", figures)); + assets.add(balanceLine("3. Investments", "OSS1_INVESTMENTS", figures)); + assets.add(balanceLine("4. Loans and Advances", "OSS1_LOANS_ADVANCES", figures)); + assets.add(countLine("Total No of Borrowers", borrowerCount(officeId, quarter.end))); + assets.add(balanceLine("5. Fixed Assets", "OSS1_FIXED_ASSETS", figures)); + assets.add(balanceLine("6. Other Assets", "OSS1_OTHER_ASSETS", figures)); + assets.add(subtotal("12. Total Assets", assets, "OSS1_CASH_IN_HAND", "OSS1_BALANCE_WITH_BANKS", + "OSS1_INVESTMENTS", "OSS1_LOANS_ADVANCES", "OSS1_FIXED_ASSETS", "OSS1_OTHER_ASSETS")); + + response.put("liabilities", liabilities); + response.put("assets", assets); + + // Liabilities must equal assets. Reported rather than asserted: an imbalance is a real + // condition of the books that the filer has to see, not something to hide behind an error. + final BigDecimal totalLiabilities = amountOf(liabilities, "15. Total Liabilities"); + final BigDecimal totalAssets = amountOf(assets, "12. Total Assets"); + response.put("totalLiabilities", totalLiabilities); + response.put("totalAssets", totalAssets); + response.put("balanced", totalLiabilities.compareTo(totalAssets) == 0); + response.put("imbalance", totalAssets.subtract(totalLiabilities)); + + response.put("unmetRequirements", figures.unmet()); + response.put("computable", figures.anyConfigured()); + return gson.toJson(response); + } + + // ─────────────────────────── OSS 2 — RoE ─────────────────────────── + + /** + * OSS 2 - Return on Earnings, return code R071. + * + * Earnings lines report the movement over the quarter rather than a closing balance, so the + * period matters here in a way it does not on OSS 1: the same account read as a balance would + * carry forward every earlier quarter's income as well. + */ + @GET + @Path("oss2") + public String oss2(@QueryParam("quarterEndDate") final String quarterEndDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(quarterEndDateParam); + final Quarter quarter = Quarter.containing(requested); + + final Map response = baseResponse("R071", "Return on Earnings (RoE)", quarter, officeId); + response.put("basis", Map.of("fromDate", quarter.start.toString(), "toDate", quarter.end.toString(), "note", + "Earnings are the movement over " + quarter.start + " to " + quarter.end + + ", not a closing balance: an income or expense line reports what was earned or spent " + + "in the quarter.")); + + final RoleFigures figures = new RoleFigures(quarter, officeId); + + final List> earnings = new ArrayList<>(); + earnings.add(movementLine("2. Interest/discount received on loans and advances", "OSS2_INTEREST_ON_ADVANCES", + figures)); + earnings.add(movementLine("3. Interest on market lending (to banks)", "OSS2_INTEREST_ON_MARKET_LENDING", + figures)); + earnings.add(movementLine("4. Interest on investments", "OSS2_INTEREST_ON_INVESTMENTS", figures)); + earnings.add(movementLine("5. Interest on balances with RBI and inter-bank deposits", + "OSS2_INTEREST_ON_BANK_BALANCES", figures)); + earnings.add(subtotal("1. Interest/discount received (2+3+4+5)", earnings, "OSS2_INTEREST_ON_ADVANCES", + "OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES")); + earnings.add(movementLine("6. Commission, Exchange and Brokerage", "OSS2_COMMISSION_EXCHANGE_BROKERAGE", + figures)); + earnings.add(movementLine("7. Profit on sale of investments", "OSS2_PROFIT_ON_INVESTMENTS", figures)); + earnings.add(movementLine("8. Other income", "OSS2_OTHER_INCOME", figures)); + + final List> expenses = new ArrayList<>(); + expenses.add(movementLine("10. Interest paid on deposits", "OSS2_INTEREST_PAID_ON_DEPOSITS", figures)); + expenses.add(movementLine("11. Interest paid on borrowings", "OSS2_INTEREST_PAID_ON_BORROWINGS", figures)); + expenses.add(movementLine("12. Salaries, allowances and provident fund", "OSS2_STAFF_COST", figures)); + expenses.add(movementLine("13. Rent, taxes, insurance and lighting", "OSS2_RENT_TAXES_INSURANCE", figures)); + expenses.add(movementLine("14. Depreciation on bank's property", "OSS2_DEPRECIATION", figures)); + expenses.add(movementLine("15. Provisions and contingencies", "OSS2_PROVISIONS", figures)); + expenses.add(movementLine("16. Other operating expenses", "OSS2_OTHER_OPERATING_EXPENSES", figures)); + + response.put("earnings", earnings); + response.put("expenses", expenses); + + // Item 1 is the sum of items 2 to 5, so total income counts the four components and the + // standalone heads - never item 1 as well, which would count that interest twice. + final BigDecimal totalIncome = sumOfRoles(earnings, "OSS2_INTEREST_ON_ADVANCES", + "OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES", + "OSS2_COMMISSION_EXCHANGE_BROKERAGE", "OSS2_PROFIT_ON_INVESTMENTS", "OSS2_OTHER_INCOME"); + final BigDecimal totalExpenditure = sumOfRoles(expenses, "OSS2_INTEREST_PAID_ON_DEPOSITS", + "OSS2_INTEREST_PAID_ON_BORROWINGS", "OSS2_STAFF_COST", "OSS2_RENT_TAXES_INSURANCE", + "OSS2_DEPRECIATION", "OSS2_PROVISIONS", "OSS2_OTHER_OPERATING_EXPENSES"); + response.put("totalIncome", totalIncome); + response.put("totalExpenditure", totalExpenditure); + response.put("operatingProfit", totalIncome.subtract(totalExpenditure)); + + response.put("unmetRequirements", figures.unmet()); + response.put("computable", figures.anyConfigured()); + return gson.toJson(response); + } + + // ─────────────────────────── shared response scaffolding ─────────────────────────── + + /** + * The General Information block every OSS return carries, built from the institution's own + * particulars so OSS 1 and OSS 2 can never state a different bank from one another. + */ + private Map baseResponse(final String returnCode, final String returnName, final Quarter quarter, + final Long officeId) { + final Map response = new LinkedHashMap<>(); + response.put("returnCode", returnCode); + response.put("returnName", returnName); + response.put("reportingFrequency", "Quarterly"); + + final Map profile = institutionProfile(); + final List> generalInfo = new ArrayList<>(); + generalInfo.add(info("Return Name", returnName)); + generalInfo.add(info("Return Code", returnCode)); + generalInfo.add(info("Bank Name", text(profile.get("bank_name")))); + generalInfo.add(info("Bank Code", text(profile.get("bank_code")))); + generalInfo.add(info("RO Name", text(profile.get("city")))); + generalInfo.add(info("Institution Type", text(profile.get("bank_type")))); + generalInfo.add(info("Reporting Frequency", "Quarterly")); + generalInfo.add(info("Start Date", quarter.start.format(RBI_DATE))); + generalInfo.add(info("Quarter End Date", quarter.end.format(RBI_DATE))); + response.put("generalInfo", generalInfo); + + response.put("quarter", Map.of("label", quarter.label, "startDate", quarter.start.toString(), "endDate", + quarter.end.toString())); + response.put("officeId", officeId); + response.put("officeName", officeName(officeId)); + response.put("signatories", signatories(profile, quarter)); + return response; + } + + /** + * The institution's particulars. Absent columns are tolerated rather than fatal: the return + * names what is missing on the General Information sheet, which is more useful than refusing to + * compute figures that are themselves correct. + */ + private Map institutionProfile() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT bank_name, bank_code, bank_type, city, state, + authorized_person_name, authorized_person_designation, authorized_person_email, + contact_phone + FROM m_institution_profile WHERE id = 1 + """); + return rows.isEmpty() ? Map.of() : rows.get(0); + } + + /** + * The signatory block, taken from the institution's authorised person. Dated to the quarter + * end rather than to today, so a return reprinted later still reads as filed for its own + * period. + */ + private List> signatories(final Map profile, final Quarter quarter) { + final Map signatory = new LinkedHashMap<>(); + signatory.put("heading", "Authorised Reporting Official"); + signatory.put("name", text(profile.get("authorized_person_name"))); + signatory.put("designation", text(profile.get("authorized_person_designation"))); + signatory.put("email", text(profile.get("authorized_person_email"))); + signatory.put("telephoneOffice", text(profile.get("contact_phone"))); + signatory.put("place", text(profile.get("city"))); + signatory.put("date", quarter.end.format(RBI_DATE)); + return List.of(signatory); + } + + private String officeName(final Long officeId) { + if (officeId == null) { + return "All offices (consolidated)"; + } + final List names = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, + officeId); + return names.isEmpty() ? "Office " + officeId : names.get(0); + } + + private static Map info(final String label, final String value) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + // An unset particular is reported as such rather than as an empty cell, so a reviewer can + // see that it needs maintaining in the institution profile. + out.put("value", value.isBlank() ? "Not set in Institution Profile" : value); + out.put("configured", !value.isBlank()); + return out; + } + + private static String text(final Object value) { + return value == null ? "" : value.toString().trim(); + } + + // ─────────────────────────── the quarter ─────────────────────────── + + /** The reporting quarter a date falls in. RBI quarters run Apr-Jun, Jul-Sep, Oct-Dec, Jan-Mar. */ + private static final class Quarter { + + final LocalDate start; + final LocalDate end; + final String label; + + private Quarter(final LocalDate start, final LocalDate end, final String label) { + this.start = start; + this.end = end; + this.label = label; + } + + static Quarter containing(final LocalDate date) { + final int month = date.getMonthValue(); + final int startMonth = ((month - 1) / 3) * 3 + 1; + final LocalDate start = date.withMonth(startMonth).withDayOfMonth(1); + final LocalDate end = start.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth()); + return new Quarter(start, end, "Q" + ((startMonth - 1) / 3 + 1) + " " + start.getYear()); + } + } + + // ─────────────────────────── figures from the ledger ─────────────────────────── + + /** + * Resolves return lines to ledger figures, remembering which roles were unmapped or partly + * unusable so the return can report them once at the end. + */ + private final class RoleFigures { + + private final Quarter quarter; + private final Long officeId; + private final List reasons = new ArrayList<>(); + private boolean anyConfigured; + + RoleFigures(final Quarter quarter, final Long officeId) { + this.quarter = quarter; + this.officeId = officeId; + } + + /** Closing balance as at the quarter end - for the balance-sheet lines of OSS 1. */ + RoleFigure closing(final String roleKey) { + return figureFor(roleKey, null, quarter.end, officeId, this); + } + + /** Movement over the quarter - for the earnings lines of OSS 2. */ + RoleFigure movement(final String roleKey) { + return figureFor(roleKey, quarter.start, quarter.end, officeId, this); + } + + /** + * Named by the line as the return prints it rather than by the role key: the reader is + * looking at a return, and "Investments" is what they will look for on the configuration + * screen, not OSS1_INVESTMENTS. + */ + void noteUnmapped(final String roleKey) { + reasons.add(lineName(roleKey) + " — no GL accounts mapped, so this line reports nothing."); + } + + void noteExcluded(final String roleKey, final List excluded) { + reasons.add(lineName(roleKey) + " — leaves out " + String.join(", ", excluded) + + ", mapped but no longer postable. The figure shown is short by them."); + } + + void noteConfigured() { + anyConfigured = true; + } + + boolean anyConfigured() { + return anyConfigured; + } + + List unmet() { + return reasons; + } + } + + /** A role's figure, plus whether it was configured at all - zero and unset are different. */ + private static final class RoleFigure { + + final String roleKey; + boolean configured; + BigDecimal amount = BigDecimal.ZERO; + final List> accounts = new ArrayList<>(); + final List excluded = new ArrayList<>(); + + RoleFigure(final String roleKey) { + this.roleKey = roleKey; + } + } + + /** + * Sums the GL accounts pinned to a role. + * + * With {@code fromDate} null the figure is the closing balance as at {@code toDate}; with a + * from-date it is the movement between the two, which is what an earnings line reports. + * + * Every pinned account is read back, usable or not. Filtering the unusable ones out in SQL + * would shrink the figure with nothing on the return to say an account had dropped out, so + * they are carried through and named instead. + */ + private RoleFigure figureFor(final String roleKey, final LocalDate fromDate, final LocalDate toDate, + final Long officeId, final RoleFigures owner) { + final RoleFigure figure = new RoleFigure(roleKey); + + final List> accounts = jdbcTemplate.queryForList(""" + SELECT a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE c.config_key = ? + ORDER BY a.gl_code + """, roleKey); + if (accounts.isEmpty()) { + owner.noteUnmapped(roleKey); + return figure; + } + figure.configured = true; + owner.noteConfigured(); + + for (final Map account : accounts) { + final Long accountId = ((Number) account.get("id")).longValue(); + final int classification = ((Number) account.get("classification_enum")).intValue(); + final String glCode = text(account.get("gl_code")); + + // A pinned account can stop being usable after it was mapped: disabled, or turned into + // a header whose balance is the sum of its children and would double-count them. + final boolean disabled = Boolean.TRUE.equals(account.get("disabled")); + final boolean header = account.get("account_usage") != null + && ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT; + if (disabled || header) { + figure.excluded + .add(glCode + " — " + text(account.get("name")) + (disabled ? " (disabled)" : " (header)")); + final Map skipped = new LinkedHashMap<>(); + skipped.put("glAccountId", accountId); + skipped.put("glCode", glCode); + skipped.put("name", text(account.get("name"))); + skipped.put("balance", null); + skipped.put("excluded", disabled ? "disabled" : "header"); + figure.accounts.add(skipped); + continue; + } + + final BigDecimal balance = balanceOf(accountId, classification, fromDate, toDate, officeId); + figure.amount = figure.amount.add(balance); + + final Map detail = new LinkedHashMap<>(); + detail.put("glAccountId", accountId); + detail.put("glCode", glCode); + detail.put("name", text(account.get("name"))); + detail.put("balance", scaled(balance)); + figure.accounts.add(detail); + } + + if (!figure.excluded.isEmpty()) { + owner.noteExcluded(roleKey, figure.excluded); + } + return figure; + } + + /** + * One account's ledger figure, on its own natural side so a liability reports positive rather + * than as a negative asset. + * + * Reversed entries and the reversal entries that cancel them are both excluded, so a reversal + * nets to nothing rather than to twice the original. + */ + private BigDecimal balanceOf(final Long accountId, final int classification, final LocalDate fromDate, + final LocalDate toDate, final Long officeId) { + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, + COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit + FROM acc_gl_journal_entry + WHERE account_id = ? AND entry_date <= ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(); + args.add(accountId); + args.add(java.sql.Date.valueOf(toDate)); + if (fromDate != null) { + sql.append(" AND entry_date >= ?"); + args.add(java.sql.Date.valueOf(fromDate)); + } + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + final BigDecimal debit = toDecimal(row.get("total_debit")); + final BigDecimal credit = toDecimal(row.get("total_credit")); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + return debitNatured ? debit.subtract(credit) : credit.subtract(debit); + } + + /** + * A role's name as the return prints it, for the caveats. Falls back to the key so a role + * added without a name here degrades to something readable rather than to nothing. + */ + private static String lineName(final String roleKey) { + return LINE_NAMES.getOrDefault(roleKey, roleKey); + } + + private static final Map LINE_NAMES = new LinkedHashMap<>(); + static { + LINE_NAMES.put("OSS1_PAID_UP_CAPITAL", "Paid-up Capital"); + LINE_NAMES.put("OSS1_RESERVES", "Reserve Fund and other Reserves"); + LINE_NAMES.put("OSS1_PROFIT_LOSS", "Profit / Loss"); + LINE_NAMES.put("OSS1_SUBORDINATED_DEBT", "Subordinated Debt (Tier II Capital)"); + LINE_NAMES.put("OSS1_DEPOSITS", "Deposits and other Accounts"); + LINE_NAMES.put("OSS1_DEPOSITS_TERM", "Term Deposits"); + LINE_NAMES.put("OSS1_DEPOSITS_SAVINGS", "Savings Bank Deposits"); + LINE_NAMES.put("OSS1_DEPOSITS_CURRENT", "Current Deposits"); + LINE_NAMES.put("OSS1_BORROWINGS", "Borrowings"); + LINE_NAMES.put("OSS1_OTHER_LIABILITIES", "Other Liabilities and Provisions"); + LINE_NAMES.put("OSS1_CASH_IN_HAND", "Cash in Hand"); + LINE_NAMES.put("OSS1_BALANCE_WITH_BANKS", "Balances with Banks"); + LINE_NAMES.put("OSS1_INVESTMENTS", "Investments"); + LINE_NAMES.put("OSS1_LOANS_ADVANCES", "Loans and Advances"); + LINE_NAMES.put("OSS1_FIXED_ASSETS", "Fixed Assets"); + LINE_NAMES.put("OSS1_OTHER_ASSETS", "Other Assets"); + + LINE_NAMES.put("OSS2_INTEREST_ON_ADVANCES", "Interest on loans and advances"); + LINE_NAMES.put("OSS2_INTEREST_ON_MARKET_LENDING", "Interest on market lending"); + LINE_NAMES.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest on investments"); + LINE_NAMES.put("OSS2_INTEREST_ON_BANK_BALANCES", "Interest on bank balances"); + LINE_NAMES.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "Commission, Exchange and Brokerage"); + LINE_NAMES.put("OSS2_PROFIT_ON_INVESTMENTS", "Profit on sale of investments"); + LINE_NAMES.put("OSS2_OTHER_INCOME", "Other income"); + LINE_NAMES.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid on deposits"); + LINE_NAMES.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings"); + LINE_NAMES.put("OSS2_STAFF_COST", "Salaries, allowances and provident fund"); + LINE_NAMES.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting"); + LINE_NAMES.put("OSS2_DEPRECIATION", "Depreciation"); + LINE_NAMES.put("OSS2_PROVISIONS", "Provisions and contingencies"); + LINE_NAMES.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses"); + } + + /** JDBC returns SUM() as whichever numeric type the driver picks; normalise it. */ + private static BigDecimal toDecimal(final Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()); + } + + /** Rupees to the thousands the return is denominated in. */ + private static BigDecimal scaled(final BigDecimal rupees) { + return rupees.divide(RETURN_SCALE, 2, RoundingMode.HALF_UP); + } + + // ─────────────────────────── headcounts ─────────────────────────── + + /** + * Depositors as at a date - distinct clients holding a deposit account open on that date. + * Counted per client rather than per account, because a customer with three accounts is one + * depositor on the return. + * + * An account is open on the date if it was active by then and had not yet closed. Reading it + * this way, rather than by today's status, is what lets an earlier quarter be reprinted and + * still report the headcount that was true then. + */ + private Long countDepositors(final Long officeId, final LocalDate asOn, final List depositTypes) { + final StringBuilder sql = new StringBuilder(""" + SELECT COUNT(DISTINCT sa.client_id) + FROM m_savings_account sa + JOIN m_client c ON c.id = sa.client_id + WHERE sa.status_enum = ? + AND sa.activatedon_date IS NOT NULL + AND sa.activatedon_date <= ? + AND (sa.closedon_date IS NULL OR sa.closedon_date > ?) + """); + final List args = new ArrayList<>(); + args.add(SAVINGS_ACTIVE); + args.add(java.sql.Date.valueOf(asOn)); + args.add(java.sql.Date.valueOf(asOn)); + if (depositTypes != null && !depositTypes.isEmpty()) { + sql.append(" AND sa.deposit_type_enum IN (") + .append(String.join(", ", depositTypes.stream().map(t -> "?").toList())).append(")"); + args.addAll(depositTypes); + } + if (officeId != null) { + sql.append(" AND c.office_id = ?"); + args.add(officeId); + } + final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray()); + return count == null ? 0L : count; + } + + /** + * The financial year's result up to the quarter end: income earned less expenses incurred. + * + * Read across every income and expense account rather than the mapped ones, because this line + * exists to close the gap between the balance sheet and the books. A figure limited to mapped + * accounts would leave the return short by whatever was not mapped, which is the very problem + * the line is here to solve. + * + * The year starts on 1 April, as the Indian financial year does. + */ + private BigDecimal currentYearResult(final Quarter quarter, final Long officeId) { + final LocalDate yearStart = financialYearStart(quarter.end); + + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN a.classification_enum = ? + THEN CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END + ELSE CASE WHEN je.type_enum = 2 THEN -je.amount ELSE je.amount END + END), 0) AS result + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.classification_enum IN (?, ?) + AND je.entry_date >= ? AND je.entry_date <= ? + AND (je.reversed IS NULL OR je.reversed = false) + AND je.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(); + args.add(INCOME); + args.add(INCOME); + args.add(EXPENSE); + args.add(java.sql.Date.valueOf(yearStart)); + args.add(java.sql.Date.valueOf(quarter.end)); + if (officeId != null) { + sql.append(" AND je.office_id = ?"); + args.add(officeId); + } + + final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray()); + return result == null ? BigDecimal.ZERO : result; + } + + /** 1 April of the financial year a date falls in. */ + private static LocalDate financialYearStart(final LocalDate date) { + final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return LocalDate.of(year, 4, 1); + } + + /** Borrowers as at a date - distinct clients with a loan outstanding on that date. */ + private Long borrowerCount(final Long officeId, final LocalDate asOn) { + final StringBuilder sql = new StringBuilder(""" + SELECT COUNT(DISTINCT l.client_id) + FROM m_loan l + JOIN m_client c ON c.id = l.client_id + WHERE l.loan_status_id = ? + AND l.disbursedon_date IS NOT NULL + AND l.disbursedon_date <= ? + """); + final List args = new ArrayList<>(); + args.add(LOAN_ACTIVE); + args.add(java.sql.Date.valueOf(asOn)); + if (officeId != null) { + sql.append(" AND c.office_id = ?"); + args.add(officeId); + } + final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray()); + return count == null ? 0L : count; + } + + // ─────────────────────────── line builders ─────────────────────────── + + private Map balanceLine(final String label, final String roleKey, final RoleFigures figures) { + return line(label, figures.closing(roleKey)); + } + + private Map movementLine(final String label, final String roleKey, final RoleFigures figures) { + return line(label, figures.movement(roleKey)); + } + + private static Map line(final String label, final RoleFigure figure) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", figure.roleKey); + out.put("configured", figure.configured); + // Null rather than zero when unmapped: the screen must be able to tell "nothing mapped" + // from "mapped and genuinely nil". + out.put("amount", figure.configured ? scaled(figure.amount) : null); + out.put("accounts", figure.accounts); + out.put("subtotal", false); + return out; + } + + /** + * A figure the return computes for itself rather than reading from mapped accounts. + * + * It carries a role key so the subtotals can name it like any other component, but that key is + * not a configurable role: nothing can leave it unmapped, and nothing can map it wrong. + */ + private static Map derivedLine(final String label, final String roleKey, + final BigDecimal rupees) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", roleKey); + out.put("configured", true); + out.put("amount", scaled(rupees)); + out.put("accounts", List.of()); + out.put("derived", true); + out.put("subtotal", false); + return out; + } + + private static Map countLine(final String label, final Long count) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", null); + out.put("configured", true); + out.put("amount", count); + out.put("accounts", List.of()); + out.put("count", true); + out.put("subtotal", false); + return out; + } + + /** + * A derived row, summed from the roles it names. Subtotals are never mapped to accounts of + * their own - a total and its components mapped separately would disagree the moment one side + * changed - and they name their components explicitly rather than summing whatever happens to + * sit above them, so inserting a line cannot silently change a total. + */ + private static Map subtotal(final String label, final List> section, + final String... ofRoles) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", null); + out.put("configured", true); + out.put("amount", sumOfRoles(section, ofRoles)); + out.put("accounts", List.of()); + out.put("components", List.of(ofRoles)); + out.put("subtotal", true); + return out; + } + + /** The amount already computed for a labelled row, so a total is never derived twice. */ + private static BigDecimal amountOf(final List> section, final String label) { + for (final Map line : section) { + if (label.equals(line.get("label")) && line.get("amount") instanceof BigDecimal decimal) { + return decimal; + } + } + return BigDecimal.ZERO; + } + + /** + * Sums the named roles out of a section. + * + * A component that is unmapped contributes nothing, which is why the return still reports + * {@code unmetRequirements}: the total is arithmetically right for what is mapped, and short by + * whatever is not. + */ + private static BigDecimal sumOfRoles(final List> section, final String... roles) { + final Set wanted = new LinkedHashSet<>(Arrays.asList(roles)); + BigDecimal total = BigDecimal.ZERO; + for (final Map line : section) { + // Subtotals and headcounts carry no role of their own, so their key is null. Matched by + // the role rather than by a null-tolerant lookup, so a derived row can never be summed + // into the total that derives it. + final Object roleKey = line.get("roleKey"); + if (roleKey == null || !wanted.contains(roleKey.toString())) { + continue; + } + if (line.get("amount") instanceof BigDecimal decimal) { + total = total.add(decimal); + } + } + return total; + } + + private static LocalDate parseOrBusinessDate(final String param) { + return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java new file mode 100644 index 000000000..36b1a0d19 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -0,0 +1,687 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Admin screen backing API for RBI Form 1 (CRR) and Form 9 (SLR) configuration - the CRR/SLR + * percentages and the GL accounts the returns read their figures from. + * + * Both halves live on one screen because they are one decision: a percentage is meaningless without + * knowing which accounts it applies to, and an administrator who sets one and forgets the other + * produces a return that looks complete and is wrong. + * + * Roles are fixed (see {@link #ROLE_DESCRIPTIONS}); accounts are pinned to them. Unlike + * m_clearing_gl_config, a role takes MANY accounts - a bank holds current accounts at a dozen other + * banks and each counts toward the balances it maintains. + */ +@Path("/v1/rbireserveconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiReserveConfigApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG"; + + /** + * The roles a return can read, in the order the screen presents them. Held here rather than + * seeded as rows because they are structural - a role only exists if code computes something + * from it, so adding one is a code change either way. + */ + private static final Map ROLE_DESCRIPTIONS = new LinkedHashMap<>(); + static { + ROLE_DESCRIPTIONS.put("CASH_IN_HAND", + "Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained."); + ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS", + "Current account balances the bank holds with other banks. Counts toward CRR maintained. " + + "Pin every such account - a bank typically banks with several."); + ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES", + "Deposit liabilities forming the DTL base that CRR and SLR are charged on. " + + "Pin the deposit accounts and any interest accrued on them."); + ROLE_DESCRIPTIONS.put("SLR_INVESTMENTS", + "SLR-eligible investments - central and state government securities. " + + "Counts toward SLR maintained on Form 9. Exclude anything marked non-SLR."); + + // OSS 1 - Return on Assets and Liabilities. The balance-sheet heads, each read as its + // closing balance at the quarter end. + ROLE_DESCRIPTIONS.put("OSS1_PAID_UP_CAPITAL", + "Share capital paid up by members. OSS 1 liability head 1."); + ROLE_DESCRIPTIONS.put("OSS1_RESERVES", + "Reserve fund and other reserves - statutory, building, dividend equalisation, " + + "provisions for standard assets. OSS 1 liability head 2."); + ROLE_DESCRIPTIONS.put("OSS1_PROFIT_LOSS", + "Accumulated profit or loss carried on the balance sheet. OSS 1 liability head 3."); + ROLE_DESCRIPTIONS.put("OSS1_SUBORDINATED_DEBT", + "Subordinated debt counted as Tier II capital. OSS 1 liability head 5."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS", + "All deposit liabilities. OSS 1 liability head 6. Pin the deposit control accounts; " + + "the term/savings/current heads below break this same total down and are not added to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_TERM", + "Term deposits, including recurring deposits and matured term deposits. " + + "A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_SAVINGS", + "Savings bank deposits. A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_CURRENT", + "Current account deposits. A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_BORROWINGS", + "Borrowings from banks and other institutions. OSS 1 liability head 7."); + ROLE_DESCRIPTIONS.put("OSS1_OTHER_LIABILITIES", + "Other liabilities and provisions - bills payable, interest accrued, sundry creditors."); + ROLE_DESCRIPTIONS.put("OSS1_CASH_IN_HAND", + "Cash in hand on the asset side of OSS 1. Usually the same accounts as CASH_IN_HAND."); + ROLE_DESCRIPTIONS.put("OSS1_BALANCE_WITH_BANKS", + "Balances with banks - current, savings and fixed. " + + "Usually the same accounts as BALANCE_WITH_OTHER_BANKS, plus any fixed placements."); + ROLE_DESCRIPTIONS.put("OSS1_INVESTMENTS", + "All investments, SLR and non-SLR together. OSS 1 asset head 3."); + ROLE_DESCRIPTIONS.put("OSS1_LOANS_ADVANCES", + "Loans and advances to members and others. OSS 1 asset head 4."); + ROLE_DESCRIPTIONS.put("OSS1_FIXED_ASSETS", + "Premises, furniture and other fixed assets, net of depreciation."); + ROLE_DESCRIPTIONS.put("OSS1_OTHER_ASSETS", "Other assets - interest receivable, sundry debtors, stationery."); + + // OSS 2 - Return on Earnings. These read the movement over the quarter, not a balance: an + // earnings line reports what was earned or spent in the period. + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_ADVANCES", + "Interest and discount earned on loans and advances. OSS 2 item 2."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_MARKET_LENDING", + "Interest on lending to banks - call and short notice money. OSS 2 item 3."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest earned on investments. OSS 2 item 4."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_BANK_BALANCES", + "Interest on balances with RBI, inter-bank deposits and placements. OSS 2 item 5."); + ROLE_DESCRIPTIONS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", + "Commission, exchange and brokerage earned. OSS 2 item 6."); + ROLE_DESCRIPTIONS.put("OSS2_PROFIT_ON_INVESTMENTS", + "Profit on sale of investments. Pin the income account only; losses belong under provisions."); + ROLE_DESCRIPTIONS.put("OSS2_OTHER_INCOME", "Other income not covered by the heads above."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid or accrued on deposits. OSS 2 item 10."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings. OSS 2 item 11."); + ROLE_DESCRIPTIONS.put("OSS2_STAFF_COST", + "Salaries, allowances, provident fund and other staff cost. OSS 2 item 12."); + ROLE_DESCRIPTIONS.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting. OSS 2 item 13."); + ROLE_DESCRIPTIONS.put("OSS2_DEPRECIATION", "Depreciation on the bank's own property. OSS 2 item 14."); + ROLE_DESCRIPTIONS.put("OSS2_PROVISIONS", + "Provisions and contingencies, including bad debt provision and loss on investments."); + ROLE_DESCRIPTIONS.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses. OSS 2 item 16."); + } + + /** + * A readable name per role. Held next to the descriptions rather than in the screen, so a role + * added here cannot show up in the UI as a raw key nobody wrote a label for. + */ + private static final Map ROLE_LABELS = new LinkedHashMap<>(); + static { + ROLE_LABELS.put("CASH_IN_HAND", "Cash in hand"); + ROLE_LABELS.put("BALANCE_WITH_OTHER_BANKS", "Balances with other banks"); + ROLE_LABELS.put("DEMAND_TIME_LIABILITIES", "Demand and Time Liabilities (DTL)"); + ROLE_LABELS.put("SLR_INVESTMENTS", "SLR-eligible investments"); + + ROLE_LABELS.put("OSS1_PAID_UP_CAPITAL", "1. Paid-up Capital"); + ROLE_LABELS.put("OSS1_RESERVES", "2. Reserve Fund and other Reserves"); + ROLE_LABELS.put("OSS1_PROFIT_LOSS", "3. Profit / Loss"); + ROLE_LABELS.put("OSS1_SUBORDINATED_DEBT", "5. Subordinated Debt (Tier II)"); + ROLE_LABELS.put("OSS1_DEPOSITS", "6. Deposits and other Accounts"); + ROLE_LABELS.put("OSS1_DEPOSITS_TERM", "6 i. Term Deposits"); + ROLE_LABELS.put("OSS1_DEPOSITS_SAVINGS", "6 ii. Savings Bank Deposits"); + ROLE_LABELS.put("OSS1_DEPOSITS_CURRENT", "6 iii. Current Deposits"); + ROLE_LABELS.put("OSS1_BORROWINGS", "7. Borrowings"); + ROLE_LABELS.put("OSS1_OTHER_LIABILITIES", "8. Other Liabilities and Provisions"); + ROLE_LABELS.put("OSS1_CASH_IN_HAND", "Assets 1. Cash in Hand"); + ROLE_LABELS.put("OSS1_BALANCE_WITH_BANKS", "Assets 2. Balances with Banks"); + ROLE_LABELS.put("OSS1_INVESTMENTS", "Assets 3. Investments"); + ROLE_LABELS.put("OSS1_LOANS_ADVANCES", "Assets 4. Loans and Advances"); + ROLE_LABELS.put("OSS1_FIXED_ASSETS", "Assets 5. Fixed Assets"); + ROLE_LABELS.put("OSS1_OTHER_ASSETS", "Assets 6. Other Assets"); + + ROLE_LABELS.put("OSS2_INTEREST_ON_ADVANCES", "2. Interest on loans and advances"); + ROLE_LABELS.put("OSS2_INTEREST_ON_MARKET_LENDING", "3. Interest on market lending"); + ROLE_LABELS.put("OSS2_INTEREST_ON_INVESTMENTS", "4. Interest on investments"); + ROLE_LABELS.put("OSS2_INTEREST_ON_BANK_BALANCES", "5. Interest on bank balances"); + ROLE_LABELS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "6. Commission, Exchange and Brokerage"); + ROLE_LABELS.put("OSS2_PROFIT_ON_INVESTMENTS", "7. Profit on sale of investments"); + ROLE_LABELS.put("OSS2_OTHER_INCOME", "8. Other income"); + ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "10. Interest paid on deposits"); + ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "11. Interest paid on borrowings"); + ROLE_LABELS.put("OSS2_STAFF_COST", "12. Salaries, allowances and PF"); + ROLE_LABELS.put("OSS2_RENT_TAXES_INSURANCE", "13. Rent, taxes, insurance and lighting"); + ROLE_LABELS.put("OSS2_DEPRECIATION", "14. Depreciation"); + ROLE_LABELS.put("OSS2_PROVISIONS", "15. Provisions and contingencies"); + ROLE_LABELS.put("OSS2_OTHER_OPERATING_EXPENSES", "16. Other operating expenses"); + } + + /** + * Which return each role belongs to, and how it is used there, so the screen can separate a + * long list into the forms it affects and state the consequence of leaving one unmapped. + */ + private static final Map ROLE_GROUPS = new LinkedHashMap<>(); + private static final Map ROLE_GROUP_LABELS = new LinkedHashMap<>(); + private static final Map ROLE_USAGE = new LinkedHashMap<>(); + static { + ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)"); + ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities"); + ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings"); + + for (final String role : ROLE_DESCRIPTIONS.keySet()) { + if (role.startsWith("OSS1_")) { + ROLE_GROUPS.put(role, "OSS1"); + ROLE_USAGE.put(role, "OSS 1 (RAL) — closing balance at quarter end"); + } else if (role.startsWith("OSS2_")) { + ROLE_GROUPS.put(role, "OSS2"); + ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter"); + } else { + ROLE_GROUPS.put(role, "RESERVE"); + if ("SLR_INVESTMENTS".equals(role)) { + ROLE_USAGE.put(role, "Form 9 (SLR) — maintained"); + } else if ("DEMAND_TIME_LIABILITIES".equals(role)) { + ROLE_USAGE.put(role, "Form 1 & Form 9 — base"); + } else { + ROLE_USAGE.put(role, "Form 1 (CRR) — maintained"); + } + } + } + } + + /** + * SAVINGS_DEMAND is the share of savings balances RBI treats as a demand liability. It sits + * beside CRR and SLR because it behaves like them: set by RBI, changed by circular, and + * effective-dated so a refiled return reproduces the split that was in force. + */ + private static final List RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND"); + + /** The four treatments a head can be given, each naming a branch the returns already have. */ + private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO"); + + private static final Map USAGE_DESCRIPTIONS = new LinkedHashMap<>(); + static { + USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head"); + USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL"); + USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward what is maintained against the requirement"); + USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total"); + } + + /** How each built-in role is treated, so the screen can describe built-ins and additions alike. */ + private static final Map BUILT_IN_USAGE = new LinkedHashMap<>(); + static { + BUILT_IN_USAGE.put("DEMAND_TIME_LIABILITIES", "LIABILITY"); + BUILT_IN_USAGE.put("BALANCE_WITH_OTHER_BANKS", "MAINTAINED"); + BUILT_IN_USAGE.put("CASH_IN_HAND", "MAINTAINED"); + BUILT_IN_USAGE.put("SLR_INVESTMENTS", "MAINTAINED"); + } + + /** Roles whose accounts carry a Demand/Time class. */ + private static final java.util.Set LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES"); + + private static final String DEMAND = "DEMAND"; + private static final String TIME = "TIME"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── read ─────────────────────────── + + @GET + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + final Map out = new LinkedHashMap<>(); + out.put("rates", rates()); + out.put("glRoles", glRoles()); + return gson.toJson(out); + } + + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + // Header accounts are excluded deliberately. A header carries no journal entry of its own - + // its balance is the sum of its children - so pinning both a header and any child would + // double-count that child. Only postable detail accounts are offered. + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code, classification_enum + FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 + ORDER BY gl_code + """); + final List> options = new ArrayList<>(); + for (final Map a : accounts) { + final Map o = new LinkedHashMap<>(); + o.put("id", a.get("id")); + o.put("name", a.get("name")); + o.put("glCode", a.get("gl_code")); + o.put("classification", a.get("classification_enum")); + options.add(o); + } + return gson.toJson(Map.of("glAccountOptions", options, "rateTypes", RATE_TYPES)); + } + + private List> rates() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, rate_type, percentage, effective_from, is_seed_default + FROM m_rbi_reserve_rate + ORDER BY rate_type, effective_from DESC + """); + final List> out = new ArrayList<>(); + for (final Map row : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", row.get("id")); + o.put("rateType", row.get("rate_type")); + o.put("percentage", row.get("percentage")); + o.put("effectiveFrom", String.valueOf(row.get("effective_from"))); + // A placeholder shipped with the migration, not a figure this bank confirmed. + o.put("seedDefault", row.get("is_seed_default")); + out.add(o); + } + return out; + } + + /** Every role, including those with nothing pinned - the screen must show the gaps. */ + private List> glRoles() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id, c.config_key, c.gl_account_id, c.weight_percentage, c.liability_class, + a.name AS gl_account_name, a.gl_code + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + ORDER BY c.config_key, a.gl_code, c.liability_class + """); + + // Seeded with every key the configuration knows, built in or added, so a mapping against a + // head an administrator created lands in a bucket rather than being dropped as unknown. + final Map>> byKey = new LinkedHashMap<>(); + for (final String key : knownRoleKeys()) { + byKey.put(key, new ArrayList<>()); + } + for (final Map row : rows) { + final String key = (String) row.get("config_key"); + // A row whose key is no longer a known role is ignored rather than surfaced - it cannot + // affect any figure, because nothing reads that key. + final List> bucket = byKey.get(key); + if (bucket == null) { + continue; + } + final Map account = new LinkedHashMap<>(); + account.put("id", row.get("id")); + account.put("glAccountId", row.get("gl_account_id")); + account.put("glAccountName", row.get("gl_account_name")); + account.put("glCode", row.get("gl_code")); + // The portion of the balance this mapping takes, and which head it falls under. Sent + // back so the screen edits what is stored rather than re-deriving a default that would + // overwrite a deliberate split on the next save. + account.put("weightPercentage", row.get("weight_percentage")); + account.put("liabilityClass", row.get("liability_class")); + bucket.add(account); + } + + final List> out = new ArrayList<>(); + for (final Map.Entry role : ROLE_DESCRIPTIONS.entrySet()) { + final Map o = new LinkedHashMap<>(); + o.put("configKey", role.getKey()); + o.put("description", role.getValue()); + o.put("label", ROLE_LABELS.getOrDefault(role.getKey(), role.getKey())); + // Which return reads this role, so the screen can group a long list by the form it + // affects rather than presenting every role as equally relevant to every return. + final String group = ROLE_GROUPS.get(role.getKey()); + o.put("group", group); + o.put("groupLabel", ROLE_GROUP_LABELS.get(group)); + o.put("usedBy", ROLE_USAGE.get(role.getKey())); + // Built-in roles are wired into the arithmetic by name, so they cannot be renamed or + // removed - the screen greys those controls rather than offering an action that would + // have to fail. + o.put("builtIn", true); + o.put("usage", BUILT_IN_USAGE.getOrDefault(role.getKey(), "MEMO")); + o.put("classified", LIABILITY_ROLES.contains(role.getKey())); + o.put("accounts", byKey.get(role.getKey())); + out.add(o); + } + // Heads an administrator added. Appended after the built-ins so the structural roles keep + // their fixed order at the top of each group regardless of what was added later. + for (final Map row : customRoleRows()) { + final String key = (String) row.get("config_key"); + final String group = (String) row.get("role_group"); + final String usage = (String) row.get("role_usage"); + final Map o = new LinkedHashMap<>(); + o.put("configKey", key); + o.put("description", row.get("description")); + o.put("label", row.get("label")); + o.put("group", group); + o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group)); + o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage)); + o.put("builtIn", false); + o.put("usage", usage); + // A liability head carries the demand/time split; nothing else does. + o.put("classified", "LIABILITY".equals(usage)); + o.put("roleId", row.get("id")); + o.put("accounts", byKey.getOrDefault(key, new ArrayList<>())); + out.add(o); + } + return out; + } + + /** Administrator-defined heads, in the order the screen presents them. */ + private List> customRoleRows() { + return jdbcTemplate.queryForList(""" + SELECT id, config_key, label, description, role_group, role_usage, position + FROM m_rbi_reserve_role + ORDER BY role_group, position, label + """); + } + + /** Every role key the configuration currently knows - built in or added. */ + private java.util.Set knownRoleKeys() { + final java.util.Set keys = new java.util.LinkedHashSet<>(ROLE_DESCRIPTIONS.keySet()); + for (final Map row : customRoleRows()) { + keys.add((String) row.get("config_key")); + } + return keys; + } + + // ─────────────────────────── write ─────────────────────────── + + /** + * Replaces the whole set of accounts pinned to one role. Sending the full set rather than + * add/remove deltas keeps the screen and the stored configuration in step even if two + * administrators have the page open at once - the last save wins wholesale, instead of the two + * interleaving into a set neither of them chose. + */ + @PUT + @Path("gl/{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateGlRole(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + if (!knownRoleKeys().contains(configKey)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.role", + "'" + configKey + "' is not a known RBI reserve configuration role."); + } + final boolean classified = isClassifiedRole(configKey); + + final JsonElement root = this.fromJsonHelper.parse(body); + + // Two shapes are accepted. "mappings" carries a weight and a class per account and is what + // the screen sends; "glAccountIds" is the flat list the first version of this screen sent, + // still honoured so an older client keeps working - its accounts are taken whole, which is + // exactly what they meant before weights existed. + final List mappings = new ArrayList<>(); + final com.google.gson.JsonArray rows = root.getAsJsonObject().has("mappings") + ? root.getAsJsonObject().getAsJsonArray("mappings") + : null; + if (rows != null) { + for (final JsonElement element : rows) { + final com.google.gson.JsonObject row = element.getAsJsonObject(); + final Long accountId = row.get("glAccountId").getAsLong(); + final BigDecimal weight = row.has("weightPercentage") && !row.get("weightPercentage").isJsonNull() + ? row.get("weightPercentage").getAsBigDecimal() + : BigDecimal.valueOf(100); + final String liabilityClass = classified && row.has("liabilityClass") + && !row.get("liabilityClass").isJsonNull() ? row.get("liabilityClass").getAsString() : null; + mappings.add(new Mapping(accountId, weight, liabilityClass)); + } + } else { + final String[] ids = this.fromJsonHelper.extractArrayNamed("glAccountIds", root); + if (ids != null) { + for (final String id : ids) { + mappings.add(new Mapping(Long.valueOf(id), BigDecimal.valueOf(100), null)); + } + } + } + + final java.util.Set seen = new java.util.LinkedHashSet<>(); + for (final Mapping mapping : mappings) { + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND disabled = false AND account_usage = 1", + Integer.class, mapping.accountId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.gl.account.not.found", + "GL account " + mapping.accountId + " was not found, is disabled, or is a header account."); + } + // A weight outside 0-100 is not a split of a balance. Above 100 it would report more + // than the account holds; below zero it would subtract a liability from the base. + if (mapping.weight == null || mapping.weight.compareTo(BigDecimal.ZERO) < 0 + || mapping.weight.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.weight.out.of.range", + "The portion taken from an account must be between 0 and 100 percent."); + } + if (mapping.liabilityClass != null && !DEMAND.equals(mapping.liabilityClass) + && !TIME.equals(mapping.liabilityClass)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.liability.class", + "A liability must be classified as " + DEMAND + " or " + TIME + "."); + } + // The same account may appear twice under one role only if the two rows are different + // heads - a demand slice and a time slice. Twice under the same head is a double count. + if (!seen.add(mapping.accountId + "/" + mapping.liabilityClass)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.duplicate.mapping", + "GL account " + mapping.accountId + + " is mapped to this role twice under the same head; it would be counted twice."); + } + } + + jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", configKey); + for (final Mapping mapping : mappings) { + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_gl_config + (config_key, gl_account_id, weight_percentage, liability_class, created_on) + VALUES (?, ?, ?, ?, now()) + """, configKey, mapping.accountId, mapping.weight, mapping.liabilityClass); + } + return retrieveAll(); + } + + /** One account pinned to a role: which account, how much of it, and under which head. */ + private record Mapping(Long accountId, BigDecimal weight, String liabilityClass) {} + + /** Whether a role's accounts carry a Demand/Time class - built in or administrator-defined. */ + private boolean isClassifiedRole(final String configKey) { + if (LIABILITY_ROLES.contains(configKey)) { + return true; + } + final List> found = jdbcTemplate + .queryForList("SELECT role_usage FROM m_rbi_reserve_role WHERE config_key = ?", configKey); + return !found.isEmpty() && "LIABILITY".equals(found.get(0).get("role_usage")); + } + + /** + * Adds a head to a return - a reserve, a tax payable, a bills-payable account, anything the + * bank carries that the built-in roles do not name. + * + * The head needs no new code because it declares how it is consumed: a LIABILITY head enters the + * base under its demand or time class, an ASSET_NET head is netted off it, a MAINTAINED head + * counts toward the requirement, a MEMO head is shown and totals nothing. Each of those is a + * branch the returns already have. + */ + @POST + @Path("role") + @Consumes({ MediaType.APPLICATION_JSON }) + public String createRole(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String label = this.fromJsonHelper.extractStringNamed("label", root); + final String description = this.fromJsonHelper.extractStringNamed("description", root); + final String group = this.fromJsonHelper.extractStringNamed("group", root); + final String usage = this.fromJsonHelper.extractStringNamed("usage", root); + + if (label == null || label.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.no.label", + "A name is required for the head."); + } + if (group == null || !ROLE_GROUP_LABELS.containsKey(group)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.group", + "The head must belong to one of " + ROLE_GROUP_LABELS.keySet() + "."); + } + if (usage == null || !ROLE_USAGES.contains(usage)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.usage", + "How the head is used must be one of " + ROLE_USAGES + "."); + } + + // The key is derived from the name rather than typed, so an administrator naming a head does + // not also have to invent a stable identifier - and cannot collide with a built-in role by + // choosing one of its names. + final String base = "CUSTOM_" + label.toUpperCase(java.util.Locale.ROOT).replaceAll("[^A-Z0-9]+", "_") + .replaceAll("^_|_$", ""); + String key = base.length() > 50 ? base.substring(0, 50) : base; + int suffix = 2; + while (knownRoleKeys().contains(key)) { + final String candidate = base + "_" + suffix++; + key = candidate.length() > 50 ? candidate.substring(0, 50) : candidate; + } + + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_role + (config_key, label, description, role_group, role_usage, position, created_on, created_by) + VALUES (?, ?, ?, ?, ?, ?, now(), ?) + """, key, label.trim(), description, group, usage, nextPosition(group), + this.context.authenticatedUser().getId()); + return retrieveAll(); + } + + /** Where a new head sits in its group: after the ones already there. */ + private int nextPosition(final String group) { + final Integer max = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(position), 100) FROM m_rbi_reserve_role WHERE role_group = ?", Integer.class, + group); + return (max == null ? 100 : max) + 1; + } + + /** + * Removes an administrator-defined head, together with the accounts pinned to it. + * + * Built-in roles are refused: the returns reference them by name, so a deleted one would not + * leave a smaller return but a broken computation. + */ + @DELETE + @Path("role/{roleId}") + public String deleteRole(@PathParam("roleId") final Long roleId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final List> found = jdbcTemplate + .queryForList("SELECT config_key, label FROM m_rbi_reserve_role WHERE id = ?", roleId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.not.found", + "Head " + roleId + " was not found, or is a built-in role that cannot be removed."); + } + final String key = (String) found.get(0).get("config_key"); + // The mappings go with it. Left behind they would reference a head nothing reads, and would + // silently reappear under a later head that happened to derive the same key. + jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", key); + jdbcTemplate.update("DELETE FROM m_rbi_reserve_role WHERE id = ?", roleId); + return retrieveAll(); + } + + /** + * Sets a rate with effect from a date. Restating a rate for a date it already carries edits that + * row; a new date adds one. Earlier rows are never rewritten, so a return filed for a past + * fortnight keeps reporting the rate that was actually in force then. + */ + @POST + @Path("rate") + @Consumes({ MediaType.APPLICATION_JSON }) + public String upsertRate(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String rateType = this.fromJsonHelper.extractStringNamed("rateType", root); + final BigDecimal percentage = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("percentage", root); + final LocalDate effectiveFrom = this.fromJsonHelper.extractLocalDateNamed("effectiveFrom", root); + + if (rateType == null || !RATE_TYPES.contains(rateType)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.rate.type", + "Rate type must be one of " + RATE_TYPES + "."); + } + if (percentage == null || percentage.compareTo(BigDecimal.ZERO) < 0 + || percentage.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.out.of.range", + "Percentage must be between 0 and 100."); + } + if (effectiveFrom == null) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.no.effective.date", + "An effective-from date is required."); + } + + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ? AND effective_from = ?", Integer.class, + rateType, java.sql.Date.valueOf(effectiveFrom)); + if (exists != null && exists > 0) { + // Saving over a seeded placeholder makes it this bank's own figure, so the flag clears + // and the returns stop captioning it as a default. + jdbcTemplate.update(""" + UPDATE m_rbi_reserve_rate SET percentage = ?, is_seed_default = false + WHERE rate_type = ? AND effective_from = ? + """, percentage, rateType, java.sql.Date.valueOf(effectiveFrom)); + } else { + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_rate (rate_type, percentage, effective_from, created_on, created_by, + is_seed_default) + VALUES (?, ?, ?, now(), ?, false) + """, rateType, percentage, java.sql.Date.valueOf(effectiveFrom), + this.context.authenticatedUser().getId()); + } + return retrieveAll(); + } + + /** + * Removes one effective-dated rate row. The last remaining row for a type is kept: with none at + * all, every return of that type would stop computing, which is a harsher outcome than an + * administrator deleting the wrong row expects. + */ + @DELETE + @Path("rate/{rateId}") + public String deleteRate(@PathParam("rateId") final Long rateId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final List> found = jdbcTemplate + .queryForList("SELECT rate_type FROM m_rbi_reserve_rate WHERE id = ?", rateId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.not.found", + "Rate " + rateId + " was not found."); + } + final String rateType = (String) found.get(0).get("rate_type"); + final Integer remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ?", + Integer.class, rateType); + if (remaining != null && remaining <= 1) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.last.row", + "The only " + rateType + " rate on record cannot be removed - edit it instead."); + } + jdbcTemplate.update("DELETE FROM m_rbi_reserve_rate WHERE id = ?", rateId); + return retrieveAll(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java new file mode 100644 index 000000000..2f641343b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -0,0 +1,892 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Live computation of RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly). + * + *

What the returns say

+ * + * Both returns answer the same shape of question: against a liability base (Demand and Time + * Liabilities), a percentage set by RBI must be maintained in a defined class of assets. Form 1 + * maintains CRR in cash and balances with banks; Form 9 maintains SLR in government securities. + * + *

The lag basis, which is the part worth reading twice

+ * + * The base and the holdings are NOT read as at the same date. RBI reckons the requirement on the + * DTL of an earlier reporting Friday and compares it against what is actually held now - that lag + * is what gives a bank time to arrange its position. So: + * + *
+ *   DTL       as at the reporting Friday on or before (reporting date - 45 days)
+ *   Required  = DTL x rate in force
+ *   Held      as at the reporting date itself
+ *   Surplus   = Held - Required
+ * 
+ * + * The 45 days come from the reporting requirement; snapping back to a Friday is what makes it a + * reporting basis rather than an arbitrary calendar day, since a Sunday has no close of business to + * reckon a balance at. Both dates are returned so the basis is auditable rather than implied - a + * reviewer can see the raw 45-day date and the Friday actually used. + * + *

Reading balances

+ * + * Balances are read the way every other report in this codebase reads them (see + * BalanceSheetApiResource, ProfitAndLossApiResource): on entry_date, excluding reversed entries AND + * the counter entries that Fineract's own revertJournalEntry posts against them. Dropping only the + * original leg of that second kind would leave its counter entry standing alone and swing the + * balance by the full amount in the wrong direction. + * + *

When something is not configured

+ * + * A role with no GL accounts pinned reports as unconfigured, and the return refuses to present a + * total that silently treats it as zero. A CRR return that reports zero holdings because nobody + * mapped the cash account is worse than one that says it cannot compute. + */ +@Path("/v1/rbireservereturn") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiReserveReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG"; + + /** Days the DTL base lags the reporting date, before snapping to a reporting Friday. */ + private static final int DTL_LAG_DAYS = 45; + + /** + * The share of the requirement that must be held on EVERY day of the fortnight, not merely on + * average across it. RBI sets this daily floor precisely so a bank cannot run the fortnight + * near-empty and make the average up on the closing day. + */ + private static final BigDecimal DAILY_MINIMUM_PERCENT = BigDecimal.valueOf(90); + + private static final int ASSET = 1; + private static final int EXPENSE = 5; + + /** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */ + private static final int POSTABLE_ACCOUNT = 1; + + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + /** The two heads liabilities are reported under. */ + private static final String DEMAND = "DEMAND"; + private static final String TIME = "TIME"; + + /** Roles where the demand/time split applies. Elsewhere an unclassified row is not a gap. */ + private static final java.util.Set LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES"); + + /** How an administrator-defined head is consumed; see m_rbi_reserve_role.role_usage. */ + private static final String USAGE_LIABILITY = "LIABILITY"; + private static final String USAGE_ASSET_NET = "ASSET_NET"; + private static final String USAGE_MAINTAINED = "MAINTAINED"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + /** + * Form 1 - CRR, filed twice a month. + * + * @param reportingDateParam + * defaults to the current business date. + */ + @GET + @Path("form1") + public String form1(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate reportingDate = parseOrBusinessDate(reportingDateParam); + final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS); + // A reporting Friday that falls on a holiday has no close of business to strike a balance + // at, so the position is taken at the preceding working day instead. + final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate); + final LocalDate dtlDate = precedingWorkingDay(nominalDtlDate); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_1"); + response.put("formName", "Form 1 - Cash Reserve Ratio (CRR)"); + response.put("frequency", "Fortnightly"); + response.put("reportingDate", reportingDate.toString()); + response.putAll(fortnightOf(reportingDate)); + + // The lag basis, spelled out so a reviewer can check it rather than trust it. + final boolean shifted = !dtlDate.equals(nominalDtlDate); + final Map basis = new LinkedHashMap<>(); + basis.put("lagDays", DTL_LAG_DAYS); + basis.put("rawLagDate", rawLagDate.toString()); + basis.put("reportingFriday", nominalDtlDate.toString()); + basis.put("dtlAsOnDate", dtlDate.toString()); + basis.put("holdingsAsOnDate", reportingDate.toString()); + basis.put("shiftedForHoliday", shifted); + basis.put("note", "DTL is taken as at the reporting Friday on or before " + rawLagDate + " (" + + DTL_LAG_DAYS + " days before the reporting date)" + + (shifted + ? "; that Friday (" + nominalDtlDate + ") was a holiday, so the position is taken at the " + + "preceding working day " + dtlDate + : "") + + "; balances maintained are taken as at " + reportingDate + "."); + response.put("basis", basis); + + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId); + final RoleFigure cash = figureFor("CASH_IN_HAND", reportingDate, officeId); + final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", reportingDate, officeId); + + final Rate rate = rateInForce("CRR", reportingDate); + response.put("rateType", "CRR"); + response.put("ratePercentage", rate == null ? null : rate.percentage); + response.put("rateIsSeedDefault", rate != null && rate.seedDefault); + + // Inter-bank assets, read on the DTL date rather than the reporting date: they are netted + // off the liability base, so they belong to the same close of business the base is struck + // at. Read on the reporting date they would net a later position against an earlier base. + final RoleFigure interBankAtDtlDate = figureFor("BALANCE_WITH_OTHER_BANKS", dtlDate, officeId); + + // Heads the bank added itself - a reserve, a tax payable, anything its chart carries that + // the four built-in roles do not name. Each declares how it is consumed, so they fold into + // the same three totals below without the arithmetic knowing what any of them is called. + final List heads = customHeads("RESERVE", dtlDate, reportingDate, officeId); + + final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY)); + final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true)); + final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false)); + final BigDecimal netted = interBankAtDtlDate.amount.add(totalOf(heads, USAGE_ASSET_NET)); + + // NDTL = DTL - inter-bank assets. CRR and SLR are charged on the NET figure; charging the + // gross would overstate the requirement by the rate times these balances. + // + // Floored at zero. The subtraction can go negative where inter-bank assets exceed the + // liabilities, and a negative base yields a negative required amount - which would make any + // holding at all, including none, report as a surplus. There is no negative reserve + // requirement: below zero the requirement is nil. + final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO); + + final List> lines = new ArrayList<>(); + lines.add(line("Demand and Time Liabilities (DTL)", dtl, dtlDate)); + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + lines.add(line(head.label, head.figure, dtlDate)); + } + } + // The two heads the liability total is built from, each stated on the face of the return as + // RBI reports them rather than left implicit inside the total above. + lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate)); + lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate)); + lines.add(line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate)); + for (final CustomHead head : heads) { + if (USAGE_ASSET_NET.equals(head.usage)) { + lines.add(line("Less: " + head.label, head.figure, dtlDate)); + } + } + lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate)); + lines.add(line("Cash in hand — counts toward CRR maintained", cash, reportingDate)); + lines.add(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate)); + for (final CustomHead head : heads) { + // A head belonging to the Memorandum or an Annexure prints under that section's own + // heading below, not here: listing it in both places would show one figure twice and + // invite a reader to add it in. + if (inNamedSection(head)) { + continue; + } + if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { + lines.add(line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure, + reportingDate)); + } + } + response.put("lines", lines); + + final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured); + response.put("computable", computable); + + if (computable) { + final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + final BigDecimal held = cash.amount.add(banks.amount).add(totalOf(heads, USAGE_MAINTAINED)); + response.put("dtlAmount", liabilities); + response.put("demandLiabilities", dtl.configured ? demand : null); + response.put("timeLiabilities", dtl.configured ? time : null); + response.put("interBankAssets", netted); + response.put("ndtlAmount", ndtl); + response.put("requiredAmount", required); + response.put("maintainedAmount", held); + response.put("surplusOrShortfall", held.subtract(required)); + response.put("compliant", held.compareTo(required) >= 0); + // What the bank actually holds, as a percentage of the same base the requirement uses - + // directly comparable with the rate above. + response.put("maintainedPercentage", percentageOf(held, ndtl)); + + // Compliance over the fortnight, not on one day. RBI requires the full requirement on + // average across the fortnight AND a daily minimum on every single day of it, so a bank + // that held nothing for thirteen days and topped up on the last one fails even though + // its closing position looks ample. + final LocalDate fortnightStart = LocalDate.parse((String) response.get("fortnightStart")); + final LocalDate fortnightEnd = LocalDate.parse((String) response.get("fortnightEnd")); + final List maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS")); + maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED)); + + // Only up to the reporting date: days after it have not happened, and averaging an + // unfinished fortnight over its whole length would understate what is held so far. + final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd; + final Map daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles); + + final BigDecimal average = (BigDecimal) daily.get("average"); + final BigDecimal lowest = (BigDecimal) daily.get("lowest"); + final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2, + RoundingMode.HALF_UP); + + daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd)); + daily.put("requiredAverage", required); + daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT); + daily.put("dailyMinimumAmount", dailyFloor); + daily.put("averageMet", average.compareTo(required) >= 0); + daily.put("dailyMinimumMet", lowest.compareTo(dailyFloor) >= 0); + daily.put("averageShortfall", average.subtract(required)); + response.put("maintenance", daily); + } + // The Memorandum and the two Annexures Form A carries alongside the main statement. They are + // sections of the return rather than extra lines on it, so they are returned separately and + // print under their own headings rather than being mistaken for figures entering the CRR + // arithmetic - which none of them do. + response.put("memorandum", sectionOf(heads, "Memorandum - ")); + response.put("annexureA", sectionOf(heads, "Annexure A - ")); + response.put("annexureB", sectionOf(heads, "Annexure B - ")); + + response.put("unmetRequirements", unmet(rate, "CRR", dtl, cash, banks)); + + return gson.toJson(response); + } + + /** + * The heads belonging to one titled section of the return, with the prefix stripped from each + * label so "Memorandum - Reserves" prints as "Reserves" under the Memorandum heading. + * + * Selected by label prefix rather than by a column, because a section is a presentational + * grouping: a head moves between sections by being renamed, and an administrator adding a head + * to the Memorandum does so by naming it that way rather than by learning a second concept. + */ + /** Prefixes that move a head out of the main statement and into its own titled section. */ + private static final List SECTION_PREFIXES = List.of("Memorandum - ", "Annexure A - ", "Annexure B - "); + + private static boolean inNamedSection(final CustomHead head) { + if (head.label == null) { + return false; + } + return SECTION_PREFIXES.stream().anyMatch(head.label::startsWith); + } + + private static List> sectionOf(final List heads, final String prefix) { + final List> rows = new ArrayList<>(); + for (final CustomHead head : heads) { + if (head.label == null || !head.label.startsWith(prefix)) { + continue; + } + final Map row = new LinkedHashMap<>(); + row.put("label", head.label.substring(prefix.length())); + row.put("configKey", head.figure.configKey); + row.put("configured", head.figure.configured); + row.put("amount", head.figure.configured ? head.figure.amount : null); + row.put("accounts", head.figure.accounts); + rows.add(row); + } + return rows; + } + + /** + * Form 9 - SLR, filed at the end of the month. + * + * Unlike Form 1, SLR is reported on the month-end position: base and holdings are both taken as + * at the month end, with no lag, so the return states the position actually held at close. + */ + @GET + @Path("form9") + public String form9(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(reportingDateParam); + final LocalDate monthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_9"); + response.put("formName", "Form 9 - Statutory Liquidity Ratio (SLR)"); + response.put("frequency", "Monthly"); + response.put("requestedDate", requested.toString()); + response.put("reportingDate", monthEnd.toString()); + response.put("monthStart", requested.withDayOfMonth(1).toString()); + + final Map basis = new LinkedHashMap<>(); + basis.put("dtlAsOnDate", monthEnd.toString()); + basis.put("holdingsAsOnDate", monthEnd.toString()); + basis.put("note", "SLR is reported on the month-end position: both the DTL base and the " + + "SLR-eligible investments are taken as at " + monthEnd + "."); + response.put("basis", basis); + + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId); + final RoleFigure investments = figureFor("SLR_INVESTMENTS", monthEnd, officeId); + final RoleFigure cash = figureFor("CASH_IN_HAND", monthEnd, officeId); + final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", monthEnd, officeId); + + final Rate rate = rateInForce("SLR", monthEnd); + response.put("rateType", "SLR"); + response.put("ratePercentage", rate == null ? null : rate.percentage); + response.put("rateIsSeedDefault", rate != null && rate.seedDefault); + + // The same administrator-defined heads Form 1 reads. Both dates are month-end here, so the + // base and the holdings are struck at one close of business. + final List heads = customHeads("RESERVE", monthEnd, monthEnd, officeId); + + final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY)); + final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true)); + final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false)); + final BigDecimal netted = banks.amount.add(totalOf(heads, USAGE_ASSET_NET)); + + // SLR is charged on the same NET base as CRR. Both sides are month-end here, so the + // inter-bank figure is the one already read above rather than a second read at another date. + final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO); + + final List> lines = new ArrayList<>(); + lines.add(line("Demand and Time Liabilities (DTL)", dtl, monthEnd)); + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + lines.add(line(head.label, head.figure, monthEnd)); + } + } + lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, monthEnd)); + lines.add(subLine("of which: Time Liabilities", time, dtl.configured, monthEnd)); + lines.add(line("Less: balances with other banks — deducted from the DTL base", banks, monthEnd)); + for (final CustomHead head : heads) { + if (USAGE_ASSET_NET.equals(head.usage)) { + lines.add(line("Less: " + head.label, head.figure, monthEnd)); + } + } + lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd)); + lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd)); + // Shown for completeness of the month-end liquid position; not added into SLR maintained. + // A custom MAINTAINED head IS added: it was declared as counting toward the requirement, + // and Form 9's requirement is the SLR one. + lines.add(line("Cash in hand (memo)", cash, monthEnd)); + for (final CustomHead head : heads) { + if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { + // Every such head is a memo on THIS return, including one marked MAINTAINED: only + // SLR-eligible investments count toward SLR, so the label must not suggest the + // figure was added into the maintained total when it was not. + lines.add(line(head.label + " (memo)", head.figure, monthEnd)); + } + } + response.put("lines", lines); + + final boolean computable = rate != null && dtl.configured && investments.configured; + response.put("computable", computable); + + if (computable) { + final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + // SLR admits government securities, not cash. A head marked MAINTAINED is therefore NOT + // added here the way it is on Form 1: it was declared as counting toward a requirement, + // and on Form 9 only SLR-eligible investments do. Adding it would let a cash-like head + // configured for CRR inflate the SLR position, which is the error this return exists to + // detect. A bank with more eligible securities maps them to SLR_INVESTMENTS. + final BigDecimal held = investments.amount; + response.put("dtlAmount", liabilities); + response.put("demandLiabilities", dtl.configured ? demand : null); + response.put("timeLiabilities", dtl.configured ? time : null); + response.put("interBankAssets", netted); + response.put("ndtlAmount", ndtl); + response.put("requiredAmount", required); + response.put("maintainedAmount", held); + response.put("surplusOrShortfall", held.subtract(required)); + response.put("compliant", held.compareTo(required) >= 0); + response.put("maintainedPercentage", percentageOf(held, ndtl)); + } + response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments)); + + return gson.toJson(response); + } + + // ─────────────────────────── computation ─────────────────────────── + + /** A role's balance, plus whether it was configured at all - zero and unset are different. */ + private static final class RoleFigure { + + final String configKey; + boolean configured; + BigDecimal amount = BigDecimal.ZERO; + /** + * The same total split by liability class, for the roles where RBI reports the two heads + * separately. Both stay zero on asset roles, where the distinction has no meaning. + */ + BigDecimal demandAmount = BigDecimal.ZERO; + BigDecimal timeAmount = BigDecimal.ZERO; + final List> accounts = new ArrayList<>(); + /** Accounts pinned to the role but left out of the total, with why. */ + final List excluded = new ArrayList<>(); + /** + * Liability rows carrying no DEMAND/TIME class. They are counted in the role total, so the + * return still adds up, but they cannot be placed under either head - reported so the gap is + * visible rather than showing as a total its own two halves fall short of. + */ + final List unclassified = new ArrayList<>(); + + RoleFigure(final String configKey) { + this.configKey = configKey; + } + } + + /** + * Sums the balances of every GL account pinned to a role, as at a date. + * + * Each account's balance is taken on its own natural side, so a liability role such as DTL + * reports as a positive number rather than as a negative asset-side figure. + * + * A mapping row contributes its configured PORTION of the balance, not the whole of it. RBI + * treats part of a savings balance as a demand liability and the rest as time, so the same + * account is mapped twice under one role - once per class, each with its own weight - and the + * two slices are accumulated under their respective heads. + */ + private RoleFigure figureFor(final String configKey, final LocalDate asOnDate, final Long officeId) { + final RoleFigure figure = new RoleFigure(configKey); + + // Every account pinned to the role is read back, usable or not. Excluding the unusable ones + // in SQL would shrink the maintained figure with nothing on the return to say an account had + // dropped out, so they are carried through here and reported by unmet() instead. + // + // Keyed on the mapping row rather than the account: one account can appear more than once + // under a role, and grouping by account would collapse a demand slice and a time slice into + // a single row whose weight is neither. + final List> accounts = jdbcTemplate.queryForList(""" + SELECT c.id AS mapping_id, c.weight_percentage, c.liability_class, + a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE c.config_key = ? + ORDER BY a.gl_code, c.liability_class + """, configKey); + if (accounts.isEmpty()) { + return figure; + } + figure.configured = true; + + for (final Map account : accounts) { + final Long accountId = ((Number) account.get("id")).longValue(); + final int classification = ((Number) account.get("classification_enum")).intValue(); + final String glCode = (String) account.get("gl_code"); + final String liabilityClass = (String) account.get("liability_class"); + // NOT NULL in the schema, but read defensively: a row that somehow arrived without one + // must not multiply a balance by zero and silently drop it out of the return. + final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED + : (BigDecimal) account.get("weight_percentage"); + + // A pinned account can stop being usable after it was mapped: disabled, or turned into a + // header whose balance is the sum of its children and would double-count them. Either + // way its balance must not silently enter the total - the account is named on the return + // as excluded, so the figure can be read for what it is. + final boolean disabled = Boolean.TRUE.equals(account.get("disabled")); + final boolean header = account.get("account_usage") != null + && ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT; + if (disabled || header) { + figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)")); + final Map skipped = new LinkedHashMap<>(); + skipped.put("glAccountId", accountId); + skipped.put("glCode", glCode); + skipped.put("name", account.get("name")); + skipped.put("balance", null); + skipped.put("weightPercentage", weight); + skipped.put("liabilityClass", liabilityClass); + skipped.put("excluded", disabled ? "disabled" : "header"); + figure.accounts.add(skipped); + continue; + } + + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, + COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit + FROM acc_gl_journal_entry + WHERE account_id = ? AND entry_date <= ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(accountId, java.sql.Date.valueOf(asOnDate))); + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + final BigDecimal debit = (BigDecimal) row.get("total_debit"); + final BigDecimal credit = (BigDecimal) row.get("total_credit"); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit); + + // The role takes its configured portion of the balance. Rounded to paise at the slice, + // so the demand and time halves shown on the return are the very numbers that were + // added together - rounding only the total would leave the two heads failing to sum to + // it by a paisa, which on a filed return reads as an error. + final BigDecimal contribution = weight.compareTo(HUNDRED) == 0 ? balance + : balance.multiply(weight).divide(HUNDRED, 2, RoundingMode.HALF_UP); + + figure.amount = figure.amount.add(contribution); + if (DEMAND.equals(liabilityClass)) { + figure.demandAmount = figure.demandAmount.add(contribution); + } else if (TIME.equals(liabilityClass)) { + figure.timeAmount = figure.timeAmount.add(contribution); + } else if (LIABILITY_ROLES.contains(configKey)) { + figure.unclassified.add(glCode + " — " + account.get("name")); + } + + final Map detail = new LinkedHashMap<>(); + detail.put("glAccountId", accountId); + detail.put("glCode", glCode); + detail.put("name", account.get("name")); + // Both the account's own balance and the slice taken from it. A reviewer checking a + // weighted line against the ledger needs the figure it was derived from, not only the + // result - otherwise a wrong weight and a wrong balance look identical on the return. + detail.put("accountBalance", balance); + detail.put("weightPercentage", weight); + detail.put("liabilityClass", liabilityClass); + detail.put("balance", contribution); + figure.accounts.add(detail); + } + return figure; + } + + /** An administrator-defined head, with the figure it evaluated to. */ + private static final class CustomHead { + + final String label; + final String usage; + final RoleFigure figure; + + CustomHead(final String label, final String usage, final RoleFigure figure) { + this.label = label; + this.usage = usage; + this.figure = figure; + } + } + + /** + * The heads an administrator added to a return, each evaluated as at the date its treatment + * calls for. + * + * Liabilities are read on the base date and assets on the holdings date, for the same reason the + * built-in roles are: the base is struck at an earlier close of business than the position held + * against it, and reading both at one date would compare figures from different days. + */ + /** + * The keys of administrator-defined heads with one usage - so the daily average counts whatever + * the bank declared as counting toward the requirement, not only the two built-in roles. + */ + private List customKeysWithUsage(final String group, final String usage) { + return jdbcTemplate.queryForList(""" + SELECT config_key FROM m_rbi_reserve_role + WHERE role_group = ? AND role_usage = ? + ORDER BY position, id + """, String.class, group, usage); + } + + private List customHeads(final String group, final LocalDate baseDate, final LocalDate holdingsDate, + final Long officeId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT config_key, label, role_usage + FROM m_rbi_reserve_role + WHERE role_group = ? + ORDER BY position, label + """, group); + final List heads = new ArrayList<>(); + for (final Map row : rows) { + final String key = (String) row.get("config_key"); + final String usage = (String) row.get("role_usage"); + final LocalDate asOn = USAGE_LIABILITY.equals(usage) || USAGE_ASSET_NET.equals(usage) ? baseDate + : holdingsDate; + final RoleFigure figure = figureFor(key, asOn, officeId); + // A head with nothing mapped is left off the return entirely rather than printed as a + // nil line. It was added but never finished, and an empty row invites the reader to + // treat a gap in configuration as a balance of zero. + if (figure.configured) { + heads.add(new CustomHead((String) row.get("label"), usage, figure)); + } + } + return heads; + } + + /** The total a set of custom heads contributes under one treatment. */ + private static BigDecimal totalOf(final List heads, final String usage) { + BigDecimal total = BigDecimal.ZERO; + for (final CustomHead head : heads) { + if (head.usage.equals(usage)) { + total = total.add(head.figure.amount); + } + } + return total; + } + + /** The demand or time slice those heads contribute, for the split shown on the face of the return. */ + private static BigDecimal liabilitySliceOf(final List heads, final boolean demand) { + BigDecimal total = BigDecimal.ZERO; + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + total = total.add(demand ? head.figure.demandAmount : head.figure.timeAmount); + } + } + return total; + } + + /** A rate and whether it is still the placeholder the migration seeded. */ + private static final class Rate { + + final BigDecimal percentage; + final boolean seedDefault; + + Rate(final BigDecimal percentage, final boolean seedDefault) { + this.percentage = percentage; + this.seedDefault = seedDefault; + } + } + + /** + * The rate in force on a date - the most recent one effective on or before it. Null when the + * date precedes every rate on record, which stops the return computing rather than defaulting to + * a rate nobody set. + */ + private Rate rateInForce(final String rateType, final LocalDate onDate) { + final List> found = jdbcTemplate.queryForList(""" + SELECT percentage, is_seed_default FROM m_rbi_reserve_rate + WHERE rate_type = ? AND effective_from <= ? + ORDER BY effective_from DESC LIMIT 1 + """, rateType, java.sql.Date.valueOf(onDate)); + if (found.isEmpty()) { + return null; + } + final Map row = found.get(0); + return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default"))); + } + + /** + * The reporting Friday on or before a date. RBI reckons fortnightly balances at the close of + * business on alternate Fridays; a raw calendar offset can land on a Sunday, which has no close + * of business to read a balance at. + */ + private static LocalDate reportingFridayOnOrBefore(final LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY)); + } + + /** + * The date itself when the bank was open, otherwise the working day before it. + * + * A reporting Friday that is a declared holiday has no close of business, so there is no balance + * to strike: the statutory framework takes the preceding working day instead. Sundays are + * treated as non-working alongside declared holidays; Saturdays are not, because a reporting + * Friday never falls on one and a holiday shifted back from Friday legitimately lands on + * Thursday. + * + * Bounded at ten days. A chain of holidays longer than that is a data problem rather than a + * calendar, and walking back indefinitely would silently report a balance from weeks earlier. + */ + private LocalDate precedingWorkingDay(final LocalDate date) { + LocalDate candidate = date; + for (int i = 0; i < 10; i++) { + if (!isNonWorkingDay(candidate)) { + return candidate; + } + candidate = candidate.minusDays(1); + } + return date; + } + + private boolean isNonWorkingDay(final LocalDate date) { + if (date.getDayOfWeek() == DayOfWeek.SUNDAY) { + return true; + } + // status_enum 300 is an activated holiday; a draft or deleted one does not close the bank. + final Integer holidays = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_holiday + WHERE status_enum = 300 AND ? BETWEEN from_date AND to_date + """, Integer.class, java.sql.Date.valueOf(date)); + return holidays != null && holidays > 0; + } + + /** + * The average of what was maintained across every day of the fortnight, which is the figure CRR + * compliance actually turns on. + * + * RBI does not read a single day's balance: a bank must keep at least a daily minimum and average + * the full requirement over the fortnight. A return that reported only the reporting date's + * position would call a bank compliant that held nothing for thirteen days and topped up on the + * last one. + * + * Every day is read, including holidays and Sundays: the balance stands over a closed day, and + * excluding those days would let a fortnight ending on a long weekend average away a shortfall. + */ + private Map dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId, + final List maintainedRoles) { + final List> days = new ArrayList<>(); + BigDecimal total = BigDecimal.ZERO; + BigDecimal lowest = null; + LocalDate lowestOn = null; + + for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) { + BigDecimal held = BigDecimal.ZERO; + for (final String role : maintainedRoles) { + held = held.add(figureFor(role, day, officeId).amount); + } + total = total.add(held); + if (lowest == null || held.compareTo(lowest) < 0) { + lowest = held; + lowestOn = day; + } + final Map row = new LinkedHashMap<>(); + row.put("date", day.toString()); + row.put("amount", held); + days.add(row); + } + + final Map out = new LinkedHashMap<>(); + out.put("days", days); + out.put("dayCount", days.size()); + out.put("average", days.isEmpty() ? BigDecimal.ZERO + : total.divide(BigDecimal.valueOf(days.size()), 2, RoundingMode.HALF_UP)); + out.put("lowest", lowest == null ? BigDecimal.ZERO : lowest); + out.put("lowestOn", lowestOn == null ? null : lowestOn.toString()); + return out; + } + + /** Which half of the month a reporting date falls in - Form 1 is filed once per fortnight. */ + private static Map fortnightOf(final LocalDate date) { + final boolean first = date.getDayOfMonth() <= 15; + final LocalDate start = first ? date.withDayOfMonth(1) : date.withDayOfMonth(16); + final LocalDate end = first ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth()); + final Map out = new LinkedHashMap<>(); + out.put("fortnight", first ? 1 : 2); + out.put("fortnightLabel", (first ? "First" : "Second") + " fortnight"); + out.put("fortnightStart", start.toString()); + out.put("fortnightEnd", end.toString()); + return out; + } + + /** + * A figure as a percentage of a base, or null where that percentage would not mean anything. + * + * Null on a base of zero OR LESS, not only on zero: the base is now NDTL, which is a subtraction + * and can come out negative where inter-bank assets exceed the liabilities - an unusual position, + * but one a small bank can genuinely hold. Dividing by it yields a negative "maintained %" that + * reads as a shortfall on a return where holdings are ample, which is worse than showing nothing. + */ + private static BigDecimal percentageOf(final BigDecimal amount, final BigDecimal base) { + if (base == null || base.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + return amount.multiply(HUNDRED).divide(base, 4, RoundingMode.HALF_UP); + } + + /** + * A derived row - a split of a role's total, or a figure computed from several roles. It has no + * accounts of its own because it is not read from the ledger: its parts are already listed under + * the lines it was derived from, and repeating them here would show the same balance twice. + */ + private static Map subLine(final String label, final BigDecimal amount, final boolean configured, + final LocalDate asOnDate) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("configKey", null); + out.put("configured", configured); + out.put("asOnDate", asOnDate.toString()); + out.put("amount", configured ? amount : null); + out.put("derived", true); + out.put("accounts", List.of()); + return out; + } + + private static Map line(final String label, final RoleFigure figure, final LocalDate asOnDate) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("configKey", figure.configKey); + out.put("configured", figure.configured); + out.put("asOnDate", asOnDate.toString()); + // Null rather than zero when unconfigured: the screen must be able to tell "nothing mapped" + // from "mapped and genuinely nil". + out.put("amount", figure.configured ? figure.amount : null); + out.put("accounts", figure.accounts); + return out; + } + + /** + * Human-readable reasons the return could not be computed, for the screen to show verbatim. + * + * A rate still carrying the seeded placeholder is listed here too. It does not stop the return + * computing - the figures are arithmetically right for that rate - but the rate itself is one + * nobody at the bank confirmed, so the return says so rather than presenting a required amount + * derived from a placeholder as though it were settled. + */ + private static List unmet(final Rate rate, final String rateType, final RoleFigure... figures) { + final List reasons = new ArrayList<>(); + if (rate == null) { + reasons.add("No " + rateType + " rate is on record effective on or before the reporting date. " + + "Set one under Organization > RBI Reserve Configuration."); + } else if (rate.seedDefault) { + reasons.add("The " + rateType + " rate of " + rate.percentage + + "% is the placeholder shipped with this installation, not a rate this bank has confirmed. " + + "Confirm the rate in force under Organization > RBI Reserve Configuration before filing."); + } + for (final RoleFigure figure : figures) { + if (!figure.configured) { + reasons.add("No GL accounts are mapped to " + figure.configKey + + ". Map them under Organization > RBI Reserve Configuration."); + } else if (!figure.unclassified.isEmpty()) { + // Counted in the base but placed under neither head, so the demand and time lines do + // not sum to the total printed above them. On a filed return that reads as an + // arithmetic error, so the accounts responsible are named. + reasons.add(String.join(", ", figure.unclassified) + + " — mapped to the liability base but not marked Demand or Time, so the figure is counted in " + + "NDTL while appearing under neither head. Classify them under Organization > " + + "RBI Reserve Configuration."); + } + if (!figure.excluded.isEmpty()) { + // The figure is short by these accounts' balances. Saying so is the difference + // between a reviewable return and one that under-reports without a trace. + reasons.add(figure.configKey + " leaves out " + String.join(", ", figure.excluded) + + " — mapped but no longer postable, so the balance shown excludes them. " + + "Review the mapping under Organization > RBI Reserve Configuration."); + } + } + return reasons; + } + + private static LocalDate parseOrBusinessDate(final String param) { + return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param); + } +} 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 9cbe38744..6e60a2d2b 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 @@ -227,14 +227,14 @@ public class CurrentProductsApiResource { final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); if (savingProductData.hasAccountingEnabled()) { + // Read under the product's own CURRENT type. Read as a savings product it returned the GLs of whichever + // savings product shares this id. Payment-channel and per-charge mappings are savings features a current + // product does not write, so none are returned. final Map accountingMappings = accountMappingReadPlatformService - .fetchAccountMappingDetailsForSavingsProduct(productId, savingProductData.accountingRuleTypeId()); - final Collection paymentChannelToFundSourceMappings = accountMappingReadPlatformService - .fetchPaymentTypeToFundSourceMappingsForSavingsProduct(productId); - Collection feeToGLAccountMappings = accountMappingReadPlatformService - .fetchFeeToIncomeAccountMappingsForSavingsProduct(productId); - Collection penaltyToGLAccountMappings = accountMappingReadPlatformService - .fetchPenaltyToIncomeAccountMappingsForSavingsProduct(productId); + .fetchAccountMappingDetailsForCurrentProduct(productId, savingProductData.accountingRuleTypeId()); + final Collection paymentChannelToFundSourceMappings = null; + final Collection feeToGLAccountMappings = null; + final Collection penaltyToGLAccountMappings = null; savingProductData = SavingsProductData.withAccountingDetails(savingProductData, accountingMappings, paymentChannelToFundSourceMappings, feeToGLAccountMappings, penaltyToGLAccountMappings); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java index afede6a65..9f0a4a43f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java @@ -36,6 +36,8 @@ import lombok.Getter; @Builder public class DormantAccountData { + /** SAVINGS or CURRENT: which kind of account accountId refers to. */ + private final String accountType; private final Long accountId; private final String accountNo; private final Long clientId; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java index 29c751cd6..ef48cf1c5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java @@ -23,6 +23,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.service.CurrentAccountDormancyJobService; import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.springframework.batch.core.StepContribution; @@ -38,6 +39,7 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet { private final SavingsAccountReadPlatformService savingAccountReadPlatformService; private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final CurrentAccountDormancyJobService currentAccountDormancyJobService; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { @@ -63,6 +65,9 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet { savingsAccountWritePlatformService.escheat(savingsId); } } + + // Current accounts run the same ladder on their own current product's settings. + currentAccountDormancyJobService.run(tenantLocalDate); return RepeatStatus.FINISHED; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java new file mode 100644 index 000000000..a45768f2c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepository; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.springframework.stereotype.Service; + +/** + * The cash GL a current account's deposit or withdrawal posts against. + * + *

+ * A current product maps only the customer's liability (Current Control); the cash side is the branch's cash, which + * is the same GL the cashier's Transaction Entry screen uses: the Financial Activity mapping, Cash at Main Vault first + * and Cash at Teller after it -- the order that screen picks in, so the cashier and the ledger always name the same GL. + */ +@Service +@RequiredArgsConstructor +public class CurrentAccountCashGlResolver { + + private final FinancialActivityAccountRepository financialActivityAccountRepository; + + /** The branch cash GL, or null when neither cash activity is mapped. */ + public GLAccount resolve() { + for (final FinancialActivity activity : new FinancialActivity[] { FinancialActivity.CASH_AT_MAINVAULT, + FinancialActivity.CASH_AT_TELLER }) { + final FinancialActivityAccount mapping = financialActivityAccountRepository.findByFinancialActivityType(activity.getValue()); + if (mapping != null && mapping.getGlAccount() != null) { + return mapping.getGlAccount(); + } + } + return null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java new file mode 100644 index 000000000..27b395edc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java @@ -0,0 +1,95 @@ +/** + * 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.service; + +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * The dormancy ladder for current accounts, run by the same nightly job as savings. + * + *

+ * Current accounts live in m_current_account, which the savings job never reads, so until now a current account was + * never marked inactive or dormant and never escheated. This walks the same three rungs -- Inactive, Dormant, Escheat + * -- against each account's own current product (m_current_product: is_dormancy_tracking_active and its three day + * counts), measuring idle days with DormancyClock exactly as savings does, so the two kinds of account can never + * disagree about the same customer. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CurrentAccountDormancyJobService { + + private static final int SUB_NONE = 0; + private static final int SUB_INACTIVE = 100; + private static final int SUB_DORMANT = 200; + + private final JdbcTemplate jdbcTemplate; + private final DatabaseSpecificSQLGenerator sqlGenerator; + private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService; + + public void run(final LocalDate businessDate) { + // A re-KYC restarts the clock (DormancyClock), so an account stamped Inactive/Dormant whose idle days are now + // back under the inactive threshold has been reactivated: clear the stamp before climbing the ladder again. + final int reactivated = jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = 0 WHERE id IN (" + + "SELECT ca.id FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id" + + " WHERE ca.status_enum = 300 AND ca.sub_status_enum IN (100, 200) AND cp.days_to_inactive > 0" + + " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " < cp.days_to_inactive)", businessDate); + if (reactivated > 0) { + log.info("[Dormancy] {} current accounts reactivated after re-KYC", reactivated); + } + final int inactive = stamp(businessDate, SUB_NONE, "days_to_inactive", SUB_INACTIVE); + final int dormant = stamp(businessDate, SUB_INACTIVE, "days_to_dormancy", SUB_DORMANT); + int escheated = 0; + for (final Long accountId : pending(businessDate, SUB_DORMANT, "days_to_escheat")) { + try { + if (currentAccountTransactionWritePlatformService.escheat(accountId)) { + escheated++; + } + } catch (RuntimeException e) { + log.error("[Dormancy] Escheat failed for current account {}: {}", accountId, e.getMessage(), e); + } + } + log.info("[Dormancy] Current accounts: {} marked inactive, {} marked dormant, {} escheated", inactive, dormant, escheated); + } + + /** Moves every account on rung {@code from} whose idle days reached {@code daysColumn} up to {@code to}. */ + private int stamp(final LocalDate businessDate, final int from, final String daysColumn, final int to) { + final List ids = pending(businessDate, from, daysColumn); + for (final Long accountId : ids) { + jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = ? WHERE id = ? AND status_enum = 300 AND sub_status_enum = ?", + to, accountId, from); + } + return ids.size(); + } + + private List pending(final LocalDate businessDate, final int subStatus, final String daysColumn) { + final String sql = "SELECT ca.id FROM m_current_account ca" + + " JOIN m_current_product cp ON cp.id = ca.product_id AND cp.is_dormancy_tracking_active = true" + + " WHERE ca.status_enum = 300 AND ca.sub_status_enum = ? AND cp." + daysColumn + " > 0" + + " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " >= cp." + daysColumn; + return jdbcTemplate.queryForList(sql, Long.class, subStatus, businessDate); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java index cbc3c5a63..1e0b19d61 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java @@ -23,12 +23,14 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService; import org.springframework.jdbc.core.JdbcTemplate; @@ -71,9 +73,61 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead response.put("charges", retrieveCharges(accountId)); response.put("transactions", retrieveTransactions(accountId)); response.put("accountFacilityConfig", retrieveFacilityConfig(accountId)); + addDormancy(accountId, response); return response; } + /** + * The dormancy countdown the account screen's Dormancy Risk Monitor reads, measured exactly as a savings account's + * is: from DormancyClock's last-live date (activation or the latest re-KYC), against the product's thresholds and + * the business date. + * + * The product's dormancyAlert decides whether the screen shows the monitor at all. subStatus is replaced by the + * stage the elapsed days have earned, as the savings read does, because no job stamps current accounts yet; a + * Block stamp (300 and above) is not a dormancy stage and is left alone. + */ + private void addDormancy(final Long accountId, final Map response) { + final String sql = "SELECT cp.dormancy_alert, cp.is_dormancy_tracking_active, cp.days_to_inactive, cp.days_to_dormancy," + + " cp.days_to_escheat, ca.status_enum, ca.sub_status_enum, " + DormancyClock.lastLiveOn("ca") + " AS last_live_on" + + " FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id WHERE ca.id = ?"; + final List> rows = jdbcTemplate.queryForList(sql, accountId); + if (rows.isEmpty()) { + return; + } + final Map row = rows.get(0); + final boolean tracking = toBoolean(row.get("is_dormancy_tracking_active")); + response.put("dormancyAlert", toBoolean(row.get("dormancy_alert"))); + response.put("isDormancyTrackingActive", tracking); + + final LocalDate lastLive = row.get("last_live_on") instanceof java.sql.Date d ? d.toLocalDate() : null; + response.put("lastActiveTransactionDate", lastLive == null ? null : lastLive.toString()); + if (!tracking || lastLive == null || toInt(row.get("status_enum"), 100) != 300) { + return; + } + + final Integer toInactive = row.get("days_to_inactive") == null ? null : toInt(row.get("days_to_inactive"), 0); + final Integer toDormant = row.get("days_to_dormancy") == null ? null : toInt(row.get("days_to_dormancy"), 0); + final Integer toEscheat = row.get("days_to_escheat") == null ? null : toInt(row.get("days_to_escheat"), 0); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final long idle = ChronoUnit.DAYS.between(lastLive, today); + + int subStatus = toInt(row.get("sub_status_enum"), 0); + if (subStatus < 300) { + subStatus = toDormant != null && toDormant > 0 && idle >= toDormant ? 200 + : toInactive != null && toInactive > 0 && idle >= toInactive ? 100 : 0; + response.put("subStatus", buildSubStatus(subStatus)); + } + if (subStatus < 100 && toInactive != null) { + response.put("daysToInactive", ChronoUnit.DAYS.between(today, lastLive.plusDays(toInactive))); + } + if (subStatus < 200 && toDormant != null) { + response.put("daysToDormancy", ChronoUnit.DAYS.between(today, lastLive.plusDays(toDormant))); + } + if (toEscheat != null) { + response.put("daysToEscheat", ChronoUnit.DAYS.between(today, lastLive.plusDays(toEscheat))); + } + } + private Map retrieveFacilityConfig(Long accountId) { final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?"; List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { @@ -207,7 +261,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead tt.put("rejectTransfer", false); tt.put("writtenoff", false); tt.put("overdraftFee", false); - tt.put("escheat", false); + tt.put("escheat", typeEnum == 19); tt.put("amountHold", typeEnum == 8); tt.put("amountRelease", typeEnum == 9); tt.put("accrual", false); @@ -219,6 +273,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead case 7 -> { tt.put("code", "savingsTransactionType.payCharge"); tt.put("value", "Charge Deduction"); } case 8 -> { tt.put("code", "savingsTransactionType.onHold"); tt.put("value", "Amount on hold"); } case 9 -> { tt.put("code", "savingsTransactionType.release"); tt.put("value", "Release Amount"); } + case 19 -> { tt.put("code", "savingsTransactionType.escheat"); tt.put("value", "Escheat"); } default -> { tt.put("code", "savingsTransactionType.deposit"); tt.put("value", "Deposit"); } } return tt; @@ -477,6 +532,8 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead out.put("allowOverdraft", row.get("allow_overdraft")); out.put("overdraftLimit", row.get("overdraft_limit")); out.put("minOverdraftForInterestCalculation", row.get("min_overdraft_for_interest_calculation")); + out.put("nominalAnnualInterestRateOverdraft", row.get("nominal_annual_interest_rate_overdraft")); + out.put("validForPeriod", row.get("valid_for_period")); out.put("minRequiredBalance", row.get("min_required_balance")); out.put("enforceMinRequiredBalance", row.get("enforce_min_required_balance")); out.put("onHoldFunds", row.get("on_hold_funds_derived")); @@ -576,12 +633,14 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead Map s = new LinkedHashMap<>(); s.put("id", subStatusEnum); String value = switch (subStatusEnum) { + // Same codes as savings (SavingsAccountSubStatusEnum), so both kinds of account read alike. case 0 -> "None"; case 100 -> "Inactive"; case 200 -> "Dormant"; - case 300 -> "Block"; - case 400 -> "Block Debit"; + case 300 -> "Escheat"; + case 400 -> "Block"; case 500 -> "Block Credit"; + case 600 -> "Block Debit"; default -> "None"; }; s.put("code", "SavingsAccountSubStatusEnum." + value.replace(" ", "")); @@ -589,9 +648,10 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead s.put("none", subStatusEnum == 0); s.put("inactive", subStatusEnum == 100); s.put("dormant", subStatusEnum == 200); - s.put("block", subStatusEnum == 300); - s.put("blockDebit", subStatusEnum == 400); + s.put("escheat", subStatusEnum == 300); + s.put("block", subStatusEnum == 400); s.put("blockCredit", subStatusEnum == 500); + s.put("blockDebit", subStatusEnum == 600); return s; } @@ -689,13 +749,15 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead }; } + // SavingsPostingInterestPeriodType codes -- the ones the product form saves and the account copies. private String interestPostingPeriodValue(int id) { return switch (id) { - case 1 -> "Monthly"; - case 2 -> "Quarterly"; - case 3 -> "Biannual"; - case 4 -> "Annually"; - default -> "Annually"; + case 1 -> "Daily"; + case 4 -> "Monthly"; + case 5 -> "Quarterly"; + case 6 -> "Biannual"; + case 7 -> "Annually"; + default -> "Monthly"; }; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java index c359556d1..3b9ee3487 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java @@ -34,6 +34,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; @@ -45,7 +46,9 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper; import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @@ -63,6 +66,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; private final JournalEntryRepository journalEntryRepository; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final KycRestrictionGuard kycRestrictionGuard; + private final AppUserRepositoryWrapper appUserRepositoryWrapper; + private final CurrentAccountCashGlResolver cashGlResolver; @Transactional @Override @@ -77,6 +83,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "deposit"); + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.CREDIT); this.denominationBreakdownWriter.validatePreviousDayClosed(); this.denominationBreakdownWriter.validateCashierHasMorningTransfer(); this.denominationBreakdownWriter.validateBreakdown(command, amount, "DEPOSIT"); @@ -123,6 +130,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "withdrawal"); + // A dormant account (its current product's dormancy days passed with no re-KYC) takes no debit + // until the customer is re-verified; the same rule the savings counter applies. + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT); final BigDecimal currentBalance = safeBalance(account.getAccountBalanceDerived()); final BigDecimal onHold = safeBalance(account.getOnHoldFundsDerived()); @@ -159,7 +169,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren account.getClient() != null ? account.getClient().getCifNumber() : null, txnDate, "WITHDRAWAL"); - // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR SAVINGS_REFERENCE (cash out) + // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR branch cash GL (cash out) postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal"); return new CommandProcessingResultBuilder() @@ -391,6 +401,59 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren .build(); } + /** + * Escheat, as the savings dormancy ladder does it: the balance goes to the product's escheat liability (Dr Current + * Control, Cr Escheat Liability) and the account is closed with sub-status Escheat (300) by the system user. + * + * Its own transaction, so one account that cannot be escheated never rolls back the rest of the night's batch. + * Refused -- logged, account untouched -- where the move would be wrong or unrecorded: a debit balance is money the + * customer owes, not unclaimed money; a hold is a lien someone else has on the funds; and with cash accounting a + * balance must not leave the account unless the escheat GL exists to receive it. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Override + public boolean escheat(final Long accountId) { + final CurrentAccount account = currentAccountRepository.findById(accountId).orElse(null); + if (account == null || !account.isActive()) { + return false; + } + final BigDecimal balance = safeBalance(account.getAccountBalanceDerived()); + if (balance.signum() < 0 || safeBalance(account.getOnHoldFundsDerived()).signum() > 0) { + log.warn("[Escheat] Current account {} skipped: debit balance {} or funds on hold {}", accountId, balance, + account.getOnHoldFundsDerived()); + return false; + } + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + final boolean accounted = product != null && product.getAccountingType() != null && product.getAccountingType() != 1; + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + final GLAccount escheatAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.ESCHEAT_LIABILITY.getValue()); + if (accounted && balance.signum() > 0 && (controlAccount == null || escheatAccount == null)) { + log.warn("[Escheat] Current account {} skipped: product {} has no Current Control or Escheat Liability GL mapped", + accountId, account.getProductId()); + return false; + } + + final LocalDate today = DateUtils.getBusinessLocalDate(); + if (balance.signum() > 0) { + final CurrentAccountTransaction txn = CurrentAccountTransaction.escheat(account, account.getOffice(), balance, today); + txn.setRunningBalanceDerived(BigDecimal.ZERO); + currentAccountTransactionRepository.saveAndFlush(txn); + account.setAccountBalanceDerived(BigDecimal.ZERO); + account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(balance)); + if (accounted) { + final String ref = "CAT" + txn.getId(); + final String description = "Current Account Escheat"; + postGlEntry(account, controlAccount, JournalEntryType.DEBIT, balance, today, ref, description); + postGlEntry(account, escheatAccount, JournalEntryType.CREDIT, balance, today, ref, description); + } + } + account.setSubStatusEnum(300); + account.close(today, appUserRepositoryWrapper.fetchSystemUser()); + currentAccountRepository.saveAndFlush(account); + log.info("[Escheat] Current account {} escheated, balance {} moved to escheat liability", accountId, balance); + return true; + } + // ── Helpers ───────────────────────────────────────────────────────────────── private CurrentAccount findActiveAccount(final Long accountId) { @@ -443,37 +506,51 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren } } - // GL: cashEntryType=DEBIT/CREDIT for SAVINGS_REFERENCE; controlEntryType for SAVINGS_CONTROL + /** + * GL: cashEntryType for the branch cash GL, controlEntryType for the account's own product's Current Control. + * + *

+ * Mandatory when the product keeps accounts: a GL that cannot be resolved or posted throws, rolling the whole + * transaction back, so a balance can never move without its journal entry. It used to log and return, and a + * deposit whose Current Control lookup failed moved the balance with no entry at all. + */ private void postTransactionGl(final CurrentAccount account, final CurrentAccountTransaction txn, final JournalEntryType cashEntryType, final JournalEntryType controlEntryType, final String description) { - try { - final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); - if (product == null) return; - final Integer accountingType = product.getAccountingType(); - if (accountingType == null || accountingType == 1) return; // NONE = 1 - - final GLAccount cashAccount = findGlAccount(account.getProductId(), - CashAccountsForSavings.SAVINGS_REFERENCE.getValue()); - final GLAccount controlAccount = findGlAccount(account.getProductId(), - CashAccountsForSavings.SAVINGS_CONTROL.getValue()); - - if (cashAccount == null || controlAccount == null) { - log.warn("[GL] Missing GL account mapping for current account product id={}", account.getProductId()); - return; - } - - final String ref = "CAT" + txn.getId(); - postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); - postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); - } catch (Exception e) { - log.error("[GL] Failed GL posting for CA transaction id={}: {}", txn.getId(), e.getMessage(), e); + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + if (product == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.product.not.found", + "Current product " + account.getProductId() + " of account " + account.getAccountNo() + " not found.", + account.getId()); } + final Integer accountingType = product.getAccountingType(); + if (accountingType == null || accountingType == 1) { + return; // NONE = 1: the product keeps no accounts + } + + // Cash side: the branch cash GL from the Financial Activity mapping -- the same one the cashier screen uses. + // Liability side: the account's own current product's Current Control, read under its CURRENT product type. + final GLAccount cashAccount = cashGlResolver.resolve(); + if (cashAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped", + "No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.", + account.getId()); + } + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + if (controlAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped", + "Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + + final String ref = "CAT" + txn.getId(); + postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); + postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); } private GLAccount findGlAccount(final Long productId, final int financialAccountType) { final var mapping = productToGLAccountMappingRepository .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( - productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null); + productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null); return mapping != null ? mapping.getGlAccount() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index 95216e8e9..0804c7ad7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -1,764 +1,783 @@ -/** - * 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.service; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -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.GeneralPlatformDomainRuleException; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; -import org.apache.fineract.portfolio.client.domain.Client; -import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; -import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountJointHolder; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPartner; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; -import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; -import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountNumberGenerator; -import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; -import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; -import org.apache.fineract.useradministration.domain.AppUser; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; -import org.apache.fineract.accounting.common.AccountingRuleType; -import org.apache.fineract.accounting.glaccount.domain.GLAccount; -import org.apache.fineract.accounting.journalentry.domain.JournalEntry; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; -import org.apache.fineract.portfolio.PortfolioProductType; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; -import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWritePlatformService { - - private final PlatformSecurityContext context; - private final CurrentAccountRepository currentAccountRepository; - private final CurrentProductRepository currentProductRepository; - private final ClientRepositoryWrapper clientRepository; - private final OfficeRepositoryWrapper officeRepository; - private final CurrentAccountNumberGenerator accountNumberGenerator; - private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; - private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository; - private final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository currentAccountTransactionRepository; - private final JournalEntryRepository journalEntryRepository; - private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; - - @Override - @Transactional - public CommandProcessingResult submitApplication(final JsonCommand command) { - context.authenticatedUser(); - - final JsonObject json = command.parsedJson().getAsJsonObject(); - - final Long clientId = getLong(json, "clientId"); - final Long productId = getLong(json, "productId"); - final Long officeId = getLong(json, "officeId"); - - final Client client = clientRepository.findOneWithNotFoundDetection(clientId); - final CurrentProduct product = currentProductRepository.findById(productId) - .orElseThrow(() -> new SavingsProductNotFoundException(productId)); - - if (currentAccountRepository.countActiveByClientAndProduct(clientId, productId) > 0) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.product.for.client", - "Client already has an active Current Account with the same product.", - "productId", productId); - } - final Office office = officeId != null - ? officeRepository.findOneWithNotFoundDetection(officeId) - : client.getOffice(); - - final String dateFormat = getString(json, "dateFormat"); - final String locale = getString(json, "locale"); - - LocalDate submittedOnDate = parseDate(getString(json, "submittedOnDate"), dateFormat, locale); - if (submittedOnDate == null) submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - - // accountType (Fineract enum: 1=individual, 2=group) — try int, else default 1 - Integer accountTypeEnum = 1; - // cbsAccountType — descriptive string e.g. "Standard Account" sent as "accountType" - String cbsAccountType = null; - if (json.has("accountType") && !json.get("accountType").isJsonNull()) { - final String rawAccountType = json.get("accountType").getAsString(); - try { - accountTypeEnum = Integer.parseInt(rawAccountType); - } catch (Exception ignored) { - // It's a descriptive CBS account type string, not an enum int - cbsAccountType = rawAccountType; - } - } - - boolean msme = (json.has("msme") && !json.get("msme").isJsonNull()) - ? json.get("msme").getAsBoolean() : product.isMsme(); - boolean autoDebitFacility = (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) - ? json.get("autoDebitFacility").getAsBoolean() : product.isAutoDebitFacility(); - boolean balanceSwapOption = (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) - ? json.get("balanceSwapOption").getAsBoolean() : product.isBalanceSwapOption(); - - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = CurrentAccount.createNew( - client, productId, office, null, "TEMP", submittedOnDate, accountTypeEnum, - product.currency().getCode(), product.currency().getDigitsAfterDecimal(), - product.currency().getInMultiplesOf(), - product.nominalAnnualInterestRate(), - product.getInterestCompoundingPeriodType(), product.getInterestPostingPeriodType(), - product.getInterestCalculationType(), product.getInterestCalculationDaysInYearType(), - product.isAllowOverdraft(), product.getOverdraftLimit(), - msme, autoDebitFacility, balanceSwapOption, - product.getCustomerSegment(), product.isAutoPost(), - product.getInterestCalculateSelector(), product.getAveragePeriod(), - product.getAvgBalanceCalcPeriod()); - account.setSubmittedOnByUser(user); - - boolean shareLinkingEnabled = (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) - ? json.get("shareLinkingEnabled").getAsBoolean() : product.isShareLinkingEnabled(); - account.setShareLinkingEnabled(shareLinkingEnabled); - - // Save CBS account type (descriptive name: "Standard Account", "Basic Account", etc.) - if (cbsAccountType != null) account.setCbsAccountType(cbsAccountType); - - // Copy product-level fields that are not in createNew() - account.setMinRequiredOpeningBalance(product.getMinRequiredOpeningBalance()); - account.setMinRequiredBalance(product.getMinRequiredBalance()); - account.setEnforceMinRequiredBalance(product.isEnforceMinRequiredBalance()); - account.setLockinPeriodFrequency(product.getLockinPeriodFrequency()); - account.setLockinPeriodFrequencyEnum(product.getLockinPeriodFrequencyType()); - account.setNominalAnnualInterestRateOverdraft(product.getNominalAnnualInterestRateOverdraft()); - account.setWithdrawalFeeForTransfer(product.isWithdrawalFeeApplicableForTransfer()); - account.setMinOverdraftForInterestCalculation(product.getMinOverdraftForInterestCalculation()); - account.setFrequencyFixDay(product.getFrequencyFixDay()); - account.setEnableLockinPeriod(product.isEnableLockinPeriod()); - account.setBalanceFrequency(product.getBalanceFrequency()); - account.setAccountClosedZeroBalanceInDayClosing(product.getAccountClosedZeroBalanceInDayClosing()); - account.setValidForPeriod(product.getValidForPeriod()); - account.setOdAccountClosedZeroBalanceInDayClosing(product.isOdAccountClosedZeroBalanceInDayClosing()); - account.setLienAllowed(product.isLienAllowed()); - account.setMaxAllowedLienLimit(product.getMaxAllowedLienLimit()); - account.setMinBalanceForInterestCalculation(product.getMinBalanceForInterestCalculation()); - account.setWithholdTax(product.withHoldTax()); - - if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { - account.setEstablishmentDate(parseDate(json.get("establishmentDate").getAsString(), dateFormat, locale)); - } - if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { - account.setStartOperationDate(parseDate(json.get("startOperationDate").getAsString(), dateFormat, locale)); - } - if (json.has("isNonIndividualClient") && !json.get("isNonIndividualClient").isJsonNull()) { - account.setNonIndividualClient(json.get("isNonIndividualClient").getAsBoolean()); - } - if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) { - account.setSignatoryRights(json.get("signatoryRights").getAsString()); - } - if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) { - account.setModeOfOperation(json.get("modeOfOperation").getAsString()); - } - if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) { - account.setIntroducerName(json.get("introducerName").getAsString()); - } - if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) { - account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); - } - // UI may send "introducerClientId" or "introducerId" - Long introducerClientId = getLong(json, "introducerClientId"); - if (introducerClientId == null) introducerClientId = getLong(json, "introducerId"); - if (introducerClientId != null) account.setIntroducerClientId(introducerClientId); - - if (json.has("externalId") && !json.get("externalId").isJsonNull()) { - final String extId = json.get("externalId").getAsString().trim(); - account.setExternalId(extId.isEmpty() ? null : extId); - } - if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) { - account.setAccountStatus(json.get("accountStatus").getAsString()); - } - // Override customerSegment from request if provided - if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) { - account.setCustomerSegment(json.get("customerSegment").getAsString()); - } - - // Save first to get ID - try { - currentAccountRepository.saveAndFlush(account); - } catch (RuntimeException e) { - throwIfDuplicateExternalId(e, account.getExternalId()); - throw e; - } - - // Generate account number: 00 + GL_CODE(6) + OFFICE_ID(2) + ACCOUNT_ID(5) - final String accountNo = accountNumberGenerator.generate(account); - account.updateAccountNo(accountNo); - - // Save nominees if provided in payload (same transaction) - if (json.has("nominees") && !json.get("nominees").isJsonNull()) { - clientNomineeWritePlatformService.saveNomineesIfPresent(clientId, accountNo, "CB", json.get("nominees")); - } else if (json.has("nominee") && !json.get("nominee").isJsonNull()) { - clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); - } - - // Every digital facility starts disabled at account opening, regardless of anything the - // request sends or the product defaults to -- they're switched on deliberately, one at a - // time, later, through the account's own Edit Nominee & Features screen. - account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account)); - - // Joint holders - if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { - assembleJointHolders(account, json.getAsJsonArray("jointHolders")); - } - - // Partners (non-individual) - if (json.has("partners") && json.get("partners").isJsonArray()) { - assemblePartners(account, json.getAsJsonArray("partners")); - } - - try { - currentAccountRepository.saveAndFlush(account); - } catch (RuntimeException e) { - throwIfDuplicateExternalId(e, account.getExternalId()); - throw e; - } - - log.info("Current Account created successfully: [id={}, accountNo={}, clientId={}, officeId={}, submittedOnDate={}]", - account.getId(), account.getAccountNo(), clientId, office.getId(), submittedOnDate); - - return new CommandProcessingResultBuilder() - .withEntityId(account.getId()) - .withClientId(clientId) - .withOfficeId(office.getId()) - .build(); - } - - @Override - @Transactional - public CommandProcessingResult approveApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate approvedOnDate = parseDate(getString(json, "approvedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.approve(approvedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult undoApproval(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - account.setStatusEnum(100); - account.setApprovedOnDate(null); - account.setApprovedOnByUser(null); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult activate(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - - account.activate(activatedOnDate, user); - - // Activation posts no transaction of its own -- no opening-balance deposit, no - // digital-facility charges applied or collected, regardless of what the account's facility - // config or product defaults say. Funding and any facility charges happen strictly - // afterwards, through separate transactions a clerk processes once the account is already - // active. - currentAccountRepository.saveAndFlush(account); - - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult rejectApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate rejectedOnDate = parseDate(getString(json, "rejectedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.reject(rejectedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult withdrawApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate withdrawnOnDate = parseDate(getString(json, "withdrawnOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.withdraw(withdrawnOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult closeAccount(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate closedOnDate = parseDate(getString(json, "closedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.close(closedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult modifyApplication(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - - if (account.getStatusEnum() != null) { - if (account.getStatusEnum() == 500) { - account.setStatusEnum(100); - account.setRejectedOnDate(null); - account.setRejectedOnByUser(null); - } else if (account.getStatusEnum() == 400) { - account.setStatusEnum(100); - account.setWithdrawnOnDate(null); - account.setWithdrawnOnByUser(null); - } - } - - - if (json.has("externalId") && !json.get("externalId").isJsonNull()) - account.setExternalId(json.get("externalId").getAsString()); - if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) - account.setAccountStatus(json.get("accountStatus").getAsString()); - if (json.has("accountType") && !json.get("accountType").isJsonNull()) { - final String rawAt = json.get("accountType").getAsString(); - try { Integer.parseInt(rawAt); } catch (Exception ignored) { - // descriptive CBS type — save it - account.setCbsAccountType(rawAt); - } - } - if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) - account.setCustomerSegment(json.get("customerSegment").getAsString()); - if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) - account.setModeOfOperation(json.get("modeOfOperation").getAsString()); - if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) - account.setSignatoryRights(json.get("signatoryRights").getAsString()); - if (json.has("msme") && !json.get("msme").isJsonNull()) - account.setMsme(json.get("msme").getAsBoolean()); - if (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) - account.setBalanceSwapOption(json.get("balanceSwapOption").getAsBoolean()); - if (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) - account.setAutoDebitFacility(json.get("autoDebitFacility").getAsBoolean()); - if (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) - account.setShareLinkingEnabled(json.get("shareLinkingEnabled").getAsBoolean()); - if (json.has("introducerClientId") && !json.get("introducerClientId").isJsonNull()) - account.setIntroducerClientId(json.get("introducerClientId").getAsLong()); - if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) - account.setIntroducerName(json.get("introducerName").getAsString()); - if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) - account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); - - // dates - final String dateFormat = json.has("dateFormat") && !json.get("dateFormat").isJsonNull() - ? json.get("dateFormat").getAsString() : "dd MMMM yyyy"; - if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { - try { - account.setEstablishmentDate(LocalDate.parse(json.get("establishmentDate").getAsString(), - DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) { - // intentionally ignored - } - } - if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { - try { - account.setStartOperationDate(LocalDate.parse(json.get("startOperationDate").getAsString(), - DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) { - // intentionally ignored - } - } - - // facility config - JsonObject fcJson = json.has("accountFacilityConfig") && !json.get("accountFacilityConfig").isJsonNull() - ? json.getAsJsonObject("accountFacilityConfig") : null; - if (fcJson == null && json.has("facilityConfig") && !json.get("facilityConfig").isJsonNull()) - fcJson = json.getAsJsonObject("facilityConfig"); - if (fcJson != null) account.setFacilityConfig(assembleFacilityConfig(account, fcJson)); - - // joint holders — clear and re-add - if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { - account.getJointHolders().clear(); - assembleJointHolders(account, json.getAsJsonArray("jointHolders")); - } - - // partners — clear and re-add - if (json.has("partners") && json.get("partners").isJsonArray()) { - account.getPartners().clear(); - assemblePartners(account, json.getAsJsonArray("partners")); - } - - currentAccountRepository.saveAndFlush(account); - - // nominees — replace existing with new (done after flush to ensure accountNo is stable) - if (json.has("nominees") && !json.get("nominees").isJsonNull()) { - clientNomineeWritePlatformService.replaceNomineesForAccount( - account.getClient().getId(), account.getAccountNo(), "CB", json.get("nominees")); - } - - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult deleteApplication(final Long accountId) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - currentAccountRepository.delete(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult terminateAccount(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - account.terminate(); - currentAccountRepository.saveAndFlush(account); - - final java.util.Map changes = new java.util.LinkedHashMap<>(); - changes.put("terminateTrue", true); - changes.put("status", 900); - - final Long officeId = account.getOffice() != null ? account.getOffice().getId() : null; - final Long clientId = account.getClient() != null ? account.getClient().getId() : null; - - return new CommandProcessingResultBuilder() - .withEntityId(accountId) - .withOfficeId(officeId) - .withClientId(clientId) - .withSavingsId(accountId) - .with(changes) - .build(); - } - - // ─── helpers ────────────────────────────────────────────────────────────── - - private CurrentAccount findAccount(Long accountId) { - return currentAccountRepository.findById(accountId) - .orElseThrow(() -> new SavingsAccountNotFoundException(accountId)); - } - - private CurrentAccountFacilityConfig assembleFacilityConfig(CurrentAccount account, JsonObject fcJson) { - CurrentAccountFacilityConfig cfg = account.getFacilityConfig(); - if (cfg == null) { - cfg = CurrentAccountFacilityConfig.createFor(account); - } - cfg.setAtmFacility(getBool(fcJson, "atmFacility")); - cfg.setAtmWithdrawalFacility(getBool(fcJson, "atmWithdrawalFacility")); - cfg.setUpiFacility(getBool(fcJson, "upiFacility")); - cfg.setPosTransactionsFacility(getBool(fcJson, "posTransactionsFacility")); - cfg.setChequeBook(getBool(fcJson, "chequeBook")); - cfg.setImpsFacility(getBool(fcJson, "impsFacility")); - cfg.setSmsAlerts(getBool(fcJson, "smsAlerts")); - cfg.setBbpsFacility(getBool(fcJson, "bbpsFacility")); - cfg.setInsuranceCover(getBool(fcJson, "insuranceCover")); - cfg.setBepgFacility(getBool(fcJson, "bepgFacility")); - cfg.setNeftFacility(getBool(fcJson, "neftFacility")); - cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); - cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); - cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); - cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); - cfg.setInternetBanking(getBool(fcJson, "internetBanking")); - cfg.setMobileBanking(getBool(fcJson, "mobileBanking")); - return cfg; - } - - private void assembleJointHolders(CurrentAccount account, JsonArray arr) { - for (JsonElement el : arr) { - final JsonObject jo = el.getAsJsonObject(); - final CurrentAccountJointHolder holder = new CurrentAccountJointHolder(); - holder.setCurrentAccount(account); - Long cid = getLong(jo, "clientId"); - if (cid != null) holder.setClientId(cid); - if (jo.has("cifNumber") && !jo.get("cifNumber").isJsonNull()) holder.setCifNumber(jo.get("cifNumber").getAsString()); - if (jo.has("displayName") && !jo.get("displayName").isJsonNull()) holder.setDisplayName(jo.get("displayName").getAsString()); - if (jo.has("signingAuthority") && !jo.get("signingAuthority").isJsonNull()) holder.setSigningAuthority(jo.get("signingAuthority").getAsString()); - if (jo.has("role") && !jo.get("role").isJsonNull()) holder.setRole(jo.get("role").getAsString()); - if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { - // intentionally ignored - } - } - if (jo.has("isPrimary") && !jo.get("isPrimary").isJsonNull()) holder.setPrimary(jo.get("isPrimary").getAsBoolean()); - if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) holder.setMandatory(jo.get("isMandatory").getAsBoolean()); - account.getJointHolders().add(holder); - } - } - - private void assemblePartners(CurrentAccount account, JsonArray arr) { - for (JsonElement el : arr) { - final JsonObject jo = el.getAsJsonObject(); - final CurrentAccountPartner partner = new CurrentAccountPartner(); - partner.setCurrentAccount(account); - Long cid = getLong(jo, "clientId"); - if (cid != null) partner.setClientId(cid); - if (jo.has("clientCif") && !jo.get("clientCif").isJsonNull()) partner.setClientCif(jo.get("clientCif").getAsString()); - if (jo.has("clientName") && !jo.get("clientName").isJsonNull()) partner.setClientName(jo.get("clientName").getAsString()); - if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { - // intentionally ignored - } - } - if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) partner.setMandatory(jo.get("isMandatory").getAsBoolean()); - account.getPartners().add(partner); - } - } - - private Long getLong(JsonObject obj, String key) { - if (!obj.has(key) || obj.get(key).isJsonNull()) return null; - try { - String val = obj.get(key).getAsString().trim(); - if (val.isEmpty()) return null; - return Long.parseLong(val); - } catch (Exception e) { - return null; - } - } - - private String getString(JsonObject obj, String key) { - if (!obj.has(key) || obj.get(key).isJsonNull()) return null; - String val = obj.get(key).getAsString().trim(); - return val.isEmpty() ? null : val; - } - - private boolean getBool(JsonObject obj, String key) { - return obj.has(key) && !obj.get(key).isJsonNull() && obj.get(key).getAsBoolean(); - } - - private LocalDate parseDate(String dateStr, String dateFormat, String locale) { - if (dateStr == null || dateStr.isBlank()) return null; - try { - String fmt = dateFormat != null ? dateFormat : "dd MMMM yyyy"; - return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(fmt, - locale != null ? new java.util.Locale(locale) : java.util.Locale.ENGLISH)); - } catch (Exception e) { - try { - return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("dd MMMM yyyy", java.util.Locale.ENGLISH)); - } catch (Exception ex) { - log.warn("Could not parse date: {}", dateStr); - return null; - } - } - } - - // Wrapper to carry a charge transaction together with its charge entity for GL posting - private record ChargeGlEntry( - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn, - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {} - - /** - * Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a - * {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL - * posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) -- - * activation itself no longer collects any charges. Returns the resulting balance. - */ - private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account, - final java.util.List charges, - java.math.BigDecimal runningBalance, final LocalDate transactionDate, - final java.util.List chargeEntriesOut) { - for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) { - if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) { - continue; - } - charge.setAmountPaidDerived(charge.getAmount()); - charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); - charge.setPaid(true); - runningBalance = runningBalance.subtract(charge.getAmount()); - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction - .chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate); - chargeTxn.setRunningBalanceDerived(runningBalance); - currentAccountTransactionRepository.saveAndFlush(chargeTxn); - chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge)); - log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}", - charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance); - } - return runningBalance; - } - - @Override - @Transactional - public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set newlyEnabledFacilityTypes) { - if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { - return; - } - final CurrentAccount account = findAccount(currentAccountId); - final java.util.List newCharges = new java.util.ArrayList<>(); - - for (final String facilityType : newlyEnabledFacilityTypes) { - final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); - // An inactive mapping is treated the same as an unmapped one -- see FacilityChargeAutoApplyService's - // requireMappedCharge for why. - if (mappingOpt.isEmpty() || mappingOpt.get().getCharge() == null || !Boolean.TRUE.equals(mappingOpt.get().getIsActive())) { - throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped", - "A charge has not been mapped for the '" + facilityType - + "' facility. Configure it under Products > Facility Charge Mappings before enabling it.", - facilityType); - } - final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge(); - if (charge.isDeleted() || !charge.isActive()) { - throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted", - "The charge mapped to the '" + facilityType - + "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.", - facilityType); - } - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); - account.getCharges().add(newCharge); - newCharges.add(newCharge); - } - - if (newCharges.isEmpty()) { - return; - } - - java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() - : java.math.BigDecimal.ZERO; - final java.util.List chargeEntries = new java.util.ArrayList<>(); - currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries); - - account.setAccountBalanceDerived(currentBalance); - currentAccountRepository.saveAndFlush(account); - - postGlEntries(account, java.util.List.of(), chargeEntries); - } - - // entity type for savings/current accounts in acc_gl_journal_entry - private static final int ENTITY_TYPE_SAVINGS = 2; - - private void postGlEntries(final CurrentAccount account, - final java.util.List depositTxns, - final java.util.List chargeEntries) { - try { - final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); - if (product == null) return; - - final Integer accountingRule = product.getAccountingType(); - if (accountingRule == null || accountingRule.equals(AccountingRuleType.NONE.getValue())) { - log.debug("[GL] Product {} accounting=NONE — skipping", account.getProductId()); - return; - } - - final GLAccount cashAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue()); - final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); - final GLAccount feeIncomeAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue()); - - if (cashAccount == null || controlAccount == null) { - log.warn("[GL] Missing GL account mappings for product id={} — skipping GL posting", account.getProductId()); - return; - } - - // Deposit uses CA{accountId} as transaction ref (one per activation) - final String depositRef = "CA" + account.getId(); - - // Deposits: DR SAVINGS_REFERENCE (cash in), CR SAVINGS_CONTROL (liability) - if (depositTxns != null) { - for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn : depositTxns) { - if (txn.getId() == null || txn.getAmount() == null) continue; - postEntry(account, cashAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), depositRef); - postEntry(account, controlAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), depositRef); - log.info("[GL] Deposit GL posted for account id={}, amount={}", account.getId(), txn.getAmount()); - } - } - - // Charges: each charge gets its own transaction ref = "CAC" + txn.getId() - // so each charge appears as a separate transaction in journal entries - if (chargeEntries != null && feeIncomeAccount != null) { - for (final ChargeGlEntry entry : chargeEntries) { - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn = entry.txn(); - if (txn.getId() == null || txn.getAmount() == null) continue; - final String chargeRef = "CAC" + txn.getId(); - postEntry(account, controlAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); - postEntry(account, feeIncomeAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); - log.info("[GL] Charge GL posted for account id={}, charge={}, amount={}, ref={}", account.getId(), entry.charge().getCharge().getName(), txn.getAmount(), chargeRef); - } - } - } catch (Exception e) { - log.error("[GL] Failed GL posting for current account id={}: {}", account.getId(), e.getMessage(), e); - } - } - - private GLAccount findGlAccount(final Long productId, final int financialAccountType) { - final org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping mapping = - productToGLAccountMappingRepository.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( - productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null); - return mapping != null ? mapping.getGlAccount() : null; - } - - private void postEntry(final CurrentAccount account, final GLAccount glAccount, - final JournalEntryType entryType, final java.math.BigDecimal amount, - final LocalDate date, final String transactionRef) { - final JournalEntry entry = JournalEntry.createNew( - account.getOffice(), null, glAccount, - account.getCurrencyCode(), transactionRef, - false, date, entryType, amount, - "Current Account activation", - ENTITY_TYPE_SAVINGS, account.getId(), - null, null, null, null, null); - journalEntryRepository.save(entry); - } - - private void throwIfDuplicateExternalId(final RuntimeException e, final String externalId) { - Throwable cause = e; - while (cause != null) { - final String msg = cause.getMessage(); - if (msg != null && (msg.contains("ca_external_id_UNIQUE") || msg.contains("ca_unq_external_id"))) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.externalid", - "Current Account with this External ID already exists.", - "externalId", externalId); - } - cause = cause.getCause(); - } - } - -} +/** + * 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.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountJointHolder; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPartner; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountNumberGenerator; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; +import org.apache.fineract.accounting.common.AccountingRuleType; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; +import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWritePlatformService { + + private final PlatformSecurityContext context; + private final CurrentAccountRepository currentAccountRepository; + private final CurrentProductRepository currentProductRepository; + private final ClientRepositoryWrapper clientRepository; + private final OfficeRepositoryWrapper officeRepository; + private final CurrentAccountNumberGenerator accountNumberGenerator; + private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; + private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository; + private final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository currentAccountTransactionRepository; + private final JournalEntryRepository journalEntryRepository; + private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; + private final CurrentAccountCashGlResolver cashGlResolver; + + @Override + @Transactional + public CommandProcessingResult submitApplication(final JsonCommand command) { + context.authenticatedUser(); + + final JsonObject json = command.parsedJson().getAsJsonObject(); + + final Long clientId = getLong(json, "clientId"); + final Long productId = getLong(json, "productId"); + final Long officeId = getLong(json, "officeId"); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final CurrentProduct product = currentProductRepository.findById(productId) + .orElseThrow(() -> new SavingsProductNotFoundException(productId)); + + if (currentAccountRepository.countActiveByClientAndProduct(clientId, productId) > 0) { + throw new PlatformDataIntegrityException( + "error.msg.savings.account.duplicate.product.for.client", + "Client already has an active Current Account with the same product.", + "productId", productId); + } + final Office office = officeId != null + ? officeRepository.findOneWithNotFoundDetection(officeId) + : client.getOffice(); + + final String dateFormat = getString(json, "dateFormat"); + final String locale = getString(json, "locale"); + + LocalDate submittedOnDate = parseDate(getString(json, "submittedOnDate"), dateFormat, locale); + if (submittedOnDate == null) submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + // accountType (Fineract enum: 1=individual, 2=group) — try int, else default 1 + Integer accountTypeEnum = 1; + // cbsAccountType — descriptive string e.g. "Standard Account" sent as "accountType" + String cbsAccountType = null; + if (json.has("accountType") && !json.get("accountType").isJsonNull()) { + final String rawAccountType = json.get("accountType").getAsString(); + try { + accountTypeEnum = Integer.parseInt(rawAccountType); + } catch (Exception ignored) { + // It's a descriptive CBS account type string, not an enum int + cbsAccountType = rawAccountType; + } + } + + boolean msme = (json.has("msme") && !json.get("msme").isJsonNull()) + ? json.get("msme").getAsBoolean() : product.isMsme(); + boolean autoDebitFacility = (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) + ? json.get("autoDebitFacility").getAsBoolean() : product.isAutoDebitFacility(); + boolean balanceSwapOption = (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) + ? json.get("balanceSwapOption").getAsBoolean() : product.isBalanceSwapOption(); + + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = CurrentAccount.createNew( + client, productId, office, null, "TEMP", submittedOnDate, accountTypeEnum, + product.currency().getCode(), product.currency().getDigitsAfterDecimal(), + product.currency().getInMultiplesOf(), + product.nominalAnnualInterestRate(), + product.getInterestCompoundingPeriodType(), product.getInterestPostingPeriodType(), + product.getInterestCalculationType(), product.getInterestCalculationDaysInYearType(), + product.isAllowOverdraft(), product.getOverdraftLimit(), + msme, autoDebitFacility, balanceSwapOption, + product.getCustomerSegment(), product.isAutoPost(), + product.getInterestCalculateSelector(), product.getAveragePeriod(), + product.getAvgBalanceCalcPeriod()); + account.setSubmittedOnByUser(user); + + boolean shareLinkingEnabled = (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) + ? json.get("shareLinkingEnabled").getAsBoolean() : product.isShareLinkingEnabled(); + account.setShareLinkingEnabled(shareLinkingEnabled); + + // Save CBS account type (descriptive name: "Standard Account", "Basic Account", etc.) + if (cbsAccountType != null) account.setCbsAccountType(cbsAccountType); + + // Copy product-level fields that are not in createNew() + account.setMinRequiredOpeningBalance(product.getMinRequiredOpeningBalance()); + account.setMinRequiredBalance(product.getMinRequiredBalance()); + account.setEnforceMinRequiredBalance(product.isEnforceMinRequiredBalance()); + account.setLockinPeriodFrequency(product.getLockinPeriodFrequency()); + account.setLockinPeriodFrequencyEnum(product.getLockinPeriodFrequencyType()); + account.setNominalAnnualInterestRateOverdraft(product.getNominalAnnualInterestRateOverdraft()); + account.setWithdrawalFeeForTransfer(product.isWithdrawalFeeApplicableForTransfer()); + account.setMinOverdraftForInterestCalculation(product.getMinOverdraftForInterestCalculation()); + account.setFrequencyFixDay(product.getFrequencyFixDay()); + account.setEnableLockinPeriod(product.isEnableLockinPeriod()); + account.setBalanceFrequency(product.getBalanceFrequency()); + account.setAccountClosedZeroBalanceInDayClosing(product.getAccountClosedZeroBalanceInDayClosing()); + account.setValidForPeriod(product.getValidForPeriod()); + account.setOdAccountClosedZeroBalanceInDayClosing(product.isOdAccountClosedZeroBalanceInDayClosing()); + account.setLienAllowed(product.isLienAllowed()); + account.setMaxAllowedLienLimit(product.getMaxAllowedLienLimit()); + account.setMinBalanceForInterestCalculation(product.getMinBalanceForInterestCalculation()); + account.setWithholdTax(product.withHoldTax()); + + if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { + account.setEstablishmentDate(parseDate(json.get("establishmentDate").getAsString(), dateFormat, locale)); + } + if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { + account.setStartOperationDate(parseDate(json.get("startOperationDate").getAsString(), dateFormat, locale)); + } + if (json.has("isNonIndividualClient") && !json.get("isNonIndividualClient").isJsonNull()) { + account.setNonIndividualClient(json.get("isNonIndividualClient").getAsBoolean()); + } + if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) { + account.setSignatoryRights(json.get("signatoryRights").getAsString()); + } + if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) { + account.setModeOfOperation(json.get("modeOfOperation").getAsString()); + } + if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) { + account.setIntroducerName(json.get("introducerName").getAsString()); + } + if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) { + account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); + } + // UI may send "introducerClientId" or "introducerId" + Long introducerClientId = getLong(json, "introducerClientId"); + if (introducerClientId == null) introducerClientId = getLong(json, "introducerId"); + if (introducerClientId != null) account.setIntroducerClientId(introducerClientId); + + if (json.has("externalId") && !json.get("externalId").isJsonNull()) { + final String extId = json.get("externalId").getAsString().trim(); + account.setExternalId(extId.isEmpty() ? null : extId); + } + if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) { + account.setAccountStatus(json.get("accountStatus").getAsString()); + } + // Override customerSegment from request if provided + if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) { + account.setCustomerSegment(json.get("customerSegment").getAsString()); + } + + // Save first to get ID + try { + currentAccountRepository.saveAndFlush(account); + } catch (RuntimeException e) { + throwIfDuplicateExternalId(e, account.getExternalId()); + throw e; + } + + // Generate account number: 00 + GL_CODE(6) + OFFICE_ID(2) + ACCOUNT_ID(5) + final String accountNo = accountNumberGenerator.generate(account); + account.updateAccountNo(accountNo); + + // Save nominees if provided in payload (same transaction) + if (json.has("nominees") && !json.get("nominees").isJsonNull()) { + clientNomineeWritePlatformService.saveNomineesIfPresent(clientId, accountNo, "CB", json.get("nominees")); + } else if (json.has("nominee") && !json.get("nominee").isJsonNull()) { + clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); + } + + // Every digital facility starts disabled at account opening, regardless of anything the + // request sends or the product defaults to -- they're switched on deliberately, one at a + // time, later, through the account's own Edit Nominee & Features screen. + account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account)); + + // Joint holders + if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { + assembleJointHolders(account, json.getAsJsonArray("jointHolders")); + } + + // Partners (non-individual) + if (json.has("partners") && json.get("partners").isJsonArray()) { + assemblePartners(account, json.getAsJsonArray("partners")); + } + + try { + currentAccountRepository.saveAndFlush(account); + } catch (RuntimeException e) { + throwIfDuplicateExternalId(e, account.getExternalId()); + throw e; + } + + log.info("Current Account created successfully: [id={}, accountNo={}, clientId={}, officeId={}, submittedOnDate={}]", + account.getId(), account.getAccountNo(), clientId, office.getId(), submittedOnDate); + + return new CommandProcessingResultBuilder() + .withEntityId(account.getId()) + .withClientId(clientId) + .withOfficeId(office.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult approveApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate approvedOnDate = parseDate(getString(json, "approvedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.approve(approvedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult undoApproval(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + account.setStatusEnum(100); + account.setApprovedOnDate(null); + account.setApprovedOnByUser(null); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult activate(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + account.activate(activatedOnDate, user); + + // Activation posts no transaction of its own -- no opening-balance deposit, no + // digital-facility charges applied or collected, regardless of what the account's facility + // config or product defaults say. Funding and any facility charges happen strictly + // afterwards, through separate transactions a clerk processes once the account is already + // active. + currentAccountRepository.saveAndFlush(account); + + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate rejectedOnDate = parseDate(getString(json, "rejectedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.reject(rejectedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult withdrawApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate withdrawnOnDate = parseDate(getString(json, "withdrawnOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.withdraw(withdrawnOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult closeAccount(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate closedOnDate = parseDate(getString(json, "closedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.close(closedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult modifyApplication(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + + if (account.getStatusEnum() != null) { + if (account.getStatusEnum() == 500) { + account.setStatusEnum(100); + account.setRejectedOnDate(null); + account.setRejectedOnByUser(null); + } else if (account.getStatusEnum() == 400) { + account.setStatusEnum(100); + account.setWithdrawnOnDate(null); + account.setWithdrawnOnByUser(null); + } + } + + + if (json.has("externalId") && !json.get("externalId").isJsonNull()) + account.setExternalId(json.get("externalId").getAsString()); + if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) + account.setAccountStatus(json.get("accountStatus").getAsString()); + if (json.has("accountType") && !json.get("accountType").isJsonNull()) { + final String rawAt = json.get("accountType").getAsString(); + try { Integer.parseInt(rawAt); } catch (Exception ignored) { + // descriptive CBS type — save it + account.setCbsAccountType(rawAt); + } + } + if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) + account.setCustomerSegment(json.get("customerSegment").getAsString()); + if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) + account.setModeOfOperation(json.get("modeOfOperation").getAsString()); + if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) + account.setSignatoryRights(json.get("signatoryRights").getAsString()); + if (json.has("msme") && !json.get("msme").isJsonNull()) + account.setMsme(json.get("msme").getAsBoolean()); + if (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) + account.setBalanceSwapOption(json.get("balanceSwapOption").getAsBoolean()); + if (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) + account.setAutoDebitFacility(json.get("autoDebitFacility").getAsBoolean()); + if (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) + account.setShareLinkingEnabled(json.get("shareLinkingEnabled").getAsBoolean()); + if (json.has("introducerClientId") && !json.get("introducerClientId").isJsonNull()) + account.setIntroducerClientId(json.get("introducerClientId").getAsLong()); + if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) + account.setIntroducerName(json.get("introducerName").getAsString()); + if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) + account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); + + // dates + final String dateFormat = json.has("dateFormat") && !json.get("dateFormat").isJsonNull() + ? json.get("dateFormat").getAsString() : "dd MMMM yyyy"; + if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { + try { + account.setEstablishmentDate(LocalDate.parse(json.get("establishmentDate").getAsString(), + DateTimeFormatter.ofPattern(dateFormat))); + } catch (Exception ignored) { + // intentionally ignored + } + } + if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { + try { + account.setStartOperationDate(LocalDate.parse(json.get("startOperationDate").getAsString(), + DateTimeFormatter.ofPattern(dateFormat))); + } catch (Exception ignored) { + // intentionally ignored + } + } + + // facility config + JsonObject fcJson = json.has("accountFacilityConfig") && !json.get("accountFacilityConfig").isJsonNull() + ? json.getAsJsonObject("accountFacilityConfig") : null; + if (fcJson == null && json.has("facilityConfig") && !json.get("facilityConfig").isJsonNull()) + fcJson = json.getAsJsonObject("facilityConfig"); + if (fcJson != null) account.setFacilityConfig(assembleFacilityConfig(account, fcJson)); + + // joint holders — clear and re-add + if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { + account.getJointHolders().clear(); + assembleJointHolders(account, json.getAsJsonArray("jointHolders")); + } + + // partners — clear and re-add + if (json.has("partners") && json.get("partners").isJsonArray()) { + account.getPartners().clear(); + assemblePartners(account, json.getAsJsonArray("partners")); + } + + currentAccountRepository.saveAndFlush(account); + + // nominees — replace existing with new (done after flush to ensure accountNo is stable) + if (json.has("nominees") && !json.get("nominees").isJsonNull()) { + clientNomineeWritePlatformService.replaceNomineesForAccount( + account.getClient().getId(), account.getAccountNo(), "CB", json.get("nominees")); + } + + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult deleteApplication(final Long accountId) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + currentAccountRepository.delete(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult terminateAccount(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + account.terminate(); + currentAccountRepository.saveAndFlush(account); + + final java.util.Map changes = new java.util.LinkedHashMap<>(); + changes.put("terminateTrue", true); + changes.put("status", 900); + + final Long officeId = account.getOffice() != null ? account.getOffice().getId() : null; + final Long clientId = account.getClient() != null ? account.getClient().getId() : null; + + return new CommandProcessingResultBuilder() + .withEntityId(accountId) + .withOfficeId(officeId) + .withClientId(clientId) + .withSavingsId(accountId) + .with(changes) + .build(); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private CurrentAccount findAccount(Long accountId) { + return currentAccountRepository.findById(accountId) + .orElseThrow(() -> new SavingsAccountNotFoundException(accountId)); + } + + private CurrentAccountFacilityConfig assembleFacilityConfig(CurrentAccount account, JsonObject fcJson) { + CurrentAccountFacilityConfig cfg = account.getFacilityConfig(); + if (cfg == null) { + cfg = CurrentAccountFacilityConfig.createFor(account); + } + cfg.setAtmFacility(getBool(fcJson, "atmFacility")); + cfg.setAtmWithdrawalFacility(getBool(fcJson, "atmWithdrawalFacility")); + cfg.setUpiFacility(getBool(fcJson, "upiFacility")); + cfg.setPosTransactionsFacility(getBool(fcJson, "posTransactionsFacility")); + cfg.setChequeBook(getBool(fcJson, "chequeBook")); + cfg.setImpsFacility(getBool(fcJson, "impsFacility")); + cfg.setSmsAlerts(getBool(fcJson, "smsAlerts")); + cfg.setBbpsFacility(getBool(fcJson, "bbpsFacility")); + cfg.setInsuranceCover(getBool(fcJson, "insuranceCover")); + cfg.setBepgFacility(getBool(fcJson, "bepgFacility")); + cfg.setNeftFacility(getBool(fcJson, "neftFacility")); + cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); + cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); + cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); + cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); + cfg.setInternetBanking(getBool(fcJson, "internetBanking")); + cfg.setMobileBanking(getBool(fcJson, "mobileBanking")); + return cfg; + } + + private void assembleJointHolders(CurrentAccount account, JsonArray arr) { + for (JsonElement el : arr) { + final JsonObject jo = el.getAsJsonObject(); + final CurrentAccountJointHolder holder = new CurrentAccountJointHolder(); + holder.setCurrentAccount(account); + Long cid = getLong(jo, "clientId"); + if (cid != null) holder.setClientId(cid); + if (jo.has("cifNumber") && !jo.get("cifNumber").isJsonNull()) holder.setCifNumber(jo.get("cifNumber").getAsString()); + if (jo.has("displayName") && !jo.get("displayName").isJsonNull()) holder.setDisplayName(jo.get("displayName").getAsString()); + if (jo.has("signingAuthority") && !jo.get("signingAuthority").isJsonNull()) holder.setSigningAuthority(jo.get("signingAuthority").getAsString()); + if (jo.has("role") && !jo.get("role").isJsonNull()) holder.setRole(jo.get("role").getAsString()); + if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { + try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } + } + if (jo.has("isPrimary") && !jo.get("isPrimary").isJsonNull()) holder.setPrimary(jo.get("isPrimary").getAsBoolean()); + if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) holder.setMandatory(jo.get("isMandatory").getAsBoolean()); + account.getJointHolders().add(holder); + } + } + + private void assemblePartners(CurrentAccount account, JsonArray arr) { + for (JsonElement el : arr) { + final JsonObject jo = el.getAsJsonObject(); + final CurrentAccountPartner partner = new CurrentAccountPartner(); + partner.setCurrentAccount(account); + Long cid = getLong(jo, "clientId"); + if (cid != null) partner.setClientId(cid); + if (jo.has("clientCif") && !jo.get("clientCif").isJsonNull()) partner.setClientCif(jo.get("clientCif").getAsString()); + if (jo.has("clientName") && !jo.get("clientName").isJsonNull()) partner.setClientName(jo.get("clientName").getAsString()); + if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { + try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } + } + if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) partner.setMandatory(jo.get("isMandatory").getAsBoolean()); + account.getPartners().add(partner); + } + } + + private Long getLong(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + try { + String val = obj.get(key).getAsString().trim(); + if (val.isEmpty()) return null; + return Long.parseLong(val); + } catch (Exception e) { + return null; + } + } + + private String getString(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + String val = obj.get(key).getAsString().trim(); + return val.isEmpty() ? null : val; + } + + private boolean getBool(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull() && obj.get(key).getAsBoolean(); + } + + private LocalDate parseDate(String dateStr, String dateFormat, String locale) { + if (dateStr == null || dateStr.isBlank()) return null; + try { + String fmt = dateFormat != null ? dateFormat : "dd MMMM yyyy"; + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(fmt, + locale != null ? new java.util.Locale(locale) : java.util.Locale.ENGLISH)); + } catch (Exception e) { + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("dd MMMM yyyy", java.util.Locale.ENGLISH)); + } catch (Exception ex) { + log.warn("Could not parse date: {}", dateStr); + return null; + } + } + } + + // Wrapper to carry a charge transaction together with its charge entity for GL posting + private record ChargeGlEntry( + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn, + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {} + + /** + * Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a + * {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL + * posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) -- + * activation itself no longer collects any charges. Returns the resulting balance. + */ + private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account, + final java.util.List charges, + java.math.BigDecimal runningBalance, final LocalDate transactionDate, + final java.util.List chargeEntriesOut) { + for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) { + if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) { + continue; + } + charge.setAmountPaidDerived(charge.getAmount()); + charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); + charge.setPaid(true); + runningBalance = runningBalance.subtract(charge.getAmount()); + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction + .chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate); + chargeTxn.setRunningBalanceDerived(runningBalance); + currentAccountTransactionRepository.saveAndFlush(chargeTxn); + chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge)); + log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}", + charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance); + } + return runningBalance; + } + + @Override + @Transactional + public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set newlyEnabledFacilityTypes) { + if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { + return; + } + final CurrentAccount account = findAccount(currentAccountId); + final java.util.List newCharges = new java.util.ArrayList<>(); + + for (final String facilityType : newlyEnabledFacilityTypes) { + final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); + // An inactive mapping is treated the same as an unmapped one -- see FacilityChargeAutoApplyService's + // requireMappedCharge for why. + if (mappingOpt.isEmpty() || mappingOpt.get().getCharge() == null || !Boolean.TRUE.equals(mappingOpt.get().getIsActive())) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped", + "A charge has not been mapped for the '" + facilityType + + "' facility. Configure it under Products > Facility Charge Mappings before enabling it.", + facilityType); + } + final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge(); + if (charge.isDeleted() || !charge.isActive()) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted", + "The charge mapped to the '" + facilityType + + "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.", + facilityType); + } + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); + account.getCharges().add(newCharge); + newCharges.add(newCharge); + } + + if (newCharges.isEmpty()) { + return; + } + + java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() + : java.math.BigDecimal.ZERO; + final java.util.List chargeEntries = new java.util.ArrayList<>(); + currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries); + + account.setAccountBalanceDerived(currentBalance); + currentAccountRepository.saveAndFlush(account); + + postGlEntries(account, java.util.List.of(), chargeEntries); + } + + // entity type for savings/current accounts in acc_gl_journal_entry + private static final int ENTITY_TYPE_SAVINGS = 2; + + private void postGlEntries(final CurrentAccount account, + final java.util.List depositTxns, + final java.util.List chargeEntries) { + // Mandatory when the product keeps accounts: a GL that is missing or fails to post throws and rolls the charge + // back with it, so the account is never debited without a journal entry. (It used to log and carry on.) + { + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + if (product == null) return; + + final Integer accountingRule = product.getAccountingType(); + if (accountingRule == null || accountingRule.equals(AccountingRuleType.NONE.getValue())) { + log.debug("[GL] Product {} accounting=NONE — skipping", account.getProductId()); + return; + } + + // Cash comes from the branch cash GL (Financial Activity mapping); the rest from the account's own current + // product, read under its CURRENT product type. + final GLAccount cashAccount = cashGlResolver.resolve(); + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + final GLAccount feeIncomeAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue()); + + final boolean hasDeposits = depositTxns != null && !depositTxns.isEmpty(); + final boolean hasCharges = chargeEntries != null && !chargeEntries.isEmpty(); + if (!hasDeposits && !hasCharges) { + return; + } + if (controlAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped", + "Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + if (hasCharges && feeIncomeAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.fee.income.gl.not.mapped", + "Current product '" + product.getName() + "' has no Income From Fees GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + if (hasDeposits && cashAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped", + "No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.", + account.getId()); + } + + // Deposit uses CA{accountId} as transaction ref (one per activation) + final String depositRef = "CA" + account.getId(); + + // Deposits: DR branch cash GL (cash in), CR SAVINGS_CONTROL (liability) + if (depositTxns != null && cashAccount != null) { + for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn : depositTxns) { + if (txn.getId() == null || txn.getAmount() == null) continue; + postEntry(account, cashAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), depositRef); + postEntry(account, controlAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), depositRef); + log.info("[GL] Deposit GL posted for account id={}, amount={}", account.getId(), txn.getAmount()); + } + } + + // Charges: each charge gets its own transaction ref = "CAC" + txn.getId() + // so each charge appears as a separate transaction in journal entries + if (chargeEntries != null && feeIncomeAccount != null) { + for (final ChargeGlEntry entry : chargeEntries) { + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn = entry.txn(); + if (txn.getId() == null || txn.getAmount() == null) continue; + final String chargeRef = "CAC" + txn.getId(); + postEntry(account, controlAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); + postEntry(account, feeIncomeAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); + log.info("[GL] Charge GL posted for account id={}, charge={}, amount={}, ref={}", account.getId(), entry.charge().getCharge().getName(), txn.getAmount(), chargeRef); + } + } + } + } + + private GLAccount findGlAccount(final Long productId, final int financialAccountType) { + final org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping mapping = + productToGLAccountMappingRepository.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( + productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null); + return mapping != null ? mapping.getGlAccount() : null; + } + + private void postEntry(final CurrentAccount account, final GLAccount glAccount, + final JournalEntryType entryType, final java.math.BigDecimal amount, + final LocalDate date, final String transactionRef) { + final JournalEntry entry = JournalEntry.createNew( + account.getOffice(), null, glAccount, + account.getCurrencyCode(), transactionRef, + false, date, entryType, amount, + "Current Account activation", + ENTITY_TYPE_SAVINGS, account.getId(), + null, null, null, null, null); + journalEntryRepository.save(entry); + } + + private void throwIfDuplicateExternalId(final RuntimeException e, final String externalId) { + Throwable cause = e; + while (cause != null) { + final String msg = cause.getMessage(); + if (msg != null && (msg.contains("ca_external_id_UNIQUE") || msg.contains("ca_unq_external_id"))) { + throw new PlatformDataIntegrityException( + "error.msg.savings.account.duplicate.externalid", + "Current Account with this External ID already exists.", + "externalId", externalId); + } + cause = cause.getCause(); + } + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java index 7a5c235c8..b04776d50 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java @@ -60,13 +60,22 @@ public final class DormancyClock { * "last live" date — a date that has nothing to do with this account, which has never been live * at all. */ - public static final String LAST_LIVE_ON = """ - CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE - GREATEST( - sa.activatedon_date, - COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k - WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL), - sa.activatedon_date)) - END - """; + public static final String LAST_LIVE_ON = lastLiveOn("sa"); + + /** + * The same rule over any account table aliased {@code alias} that has {@code activatedon_date} and + * {@code client_id} — a current account ({@code m_current_account}) is measured exactly as a savings + * account is. + */ + public static String lastLiveOn(final String alias) { + return """ + CASE WHEN %1$s.activatedon_date IS NULL THEN NULL ELSE + GREATEST( + %1$s.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = %1$s.client_id AND k.verified_on IS NOT NULL), + %1$s.activatedon_date)) + END + """.formatted(alias); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java index 895dd1cea..31ad20c6b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -63,6 +64,26 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead public List retrieveDormantAccounts(final Integer subStatus, final String officeName) { this.context.authenticatedUser(); + // Savings and current accounts climb the same ladder (current ones on their own current product's days), so + // the report lists both, longest idle first across the two. + final List accounts = new ArrayList<>(retrieve(subStatus, officeName, false)); + accounts.addAll(retrieve(subStatus, officeName, true)); + accounts.sort(Comparator.comparing(DormantAccountData::getDaysIdle, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(DormantAccountData::getBalance, Comparator.nullsLast(Comparator.reverseOrder()))); + return accounts; + } + + /** + * One kind of account. m_current_account and m_current_product carry the same column names as their savings + * counterparts, so the one query serves both with the tables swapped under the same aliases. DEAF remittances are + * recorded against savings accounts only, so a current account's DEAF columns come back null. + */ + private List retrieve(final Integer subStatus, final String officeName, final boolean current) { + final String accountTable = current ? "m_current_account" : "m_savings_account"; + final String productTable = current ? "m_current_product" : "m_savings_product"; + final String deafJoin = current ? "left join m_deaf_remittance dr on 1 = 0 " + : "left join m_deaf_remittance dr on dr.savings_account_id = sa.id "; + final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON; final StringBuilder sql = new StringBuilder(); @@ -76,13 +97,13 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead sql.append("dr.refunded_on as deafRefundedOn, "); sql.append(lastCustomerTxn).append(" as lastCustomerTransactionDate, "); sql.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" as daysIdle "); - sql.append("from m_savings_account sa "); + sql.append("from ").append(accountTable).append(" sa "); sql.append("inner join m_client c on c.id = sa.client_id "); sql.append("inner join m_office o on o.id = c.office_id "); - sql.append("inner join m_savings_product sp on sp.id = sa.product_id "); + sql.append("inner join ").append(productTable).append(" sp on sp.id = sa.product_id "); // Left join: an escheated account has a remittance row only once the money has gone to RBI, // and the absence of one is exactly what the report has to show. - sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id "); + sql.append(deafJoin); // An account belongs on these reports once the ladder has stamped it, or once the days // have earned it — whichever comes first. // @@ -143,11 +164,17 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead // Longest idle first: those are the accounts nearest their next mark. sql.append("order by daysIdle desc, sa.account_balance_derived desc"); - return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(), params.toArray()); + return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(current ? "CURRENT" : "SAVINGS"), params.toArray()); } private static final class DormantAccountMapper implements RowMapper { + private final String accountType; + + DormantAccountMapper(final String accountType) { + this.accountType = accountType; + } + @Override public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException { final BigDecimal balance = rs.getBigDecimal("balance"); @@ -164,7 +191,8 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead final int stamped = rs.getInt("subStatus"); final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle); - return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo")) + return DormantAccountData.builder().accountType(accountType).accountId(rs.getLong("accountId")) + .accountNo(rs.getString("accountNo")) .clientId(rs.getLong("clientId")).clientName(rs.getString("clientName")) .officeName(rs.getString("officeName")).productName(rs.getString("productName")) // A null balance reads as zero rather than as missing data — an escheated account diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index cda677df1..d263de1bc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -95,6 +95,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private final VoucherService voucherService; private final FineractRequestContextHolder fineractRequestContextHolder; private final SponsorDDWritePlatformService sponsorDDWritePlatformService; + private final org.apache.fineract.portfolio.account.service.AccountBookDebitService accountBookDebitService; // ── Maker step ───────────────────────────────────────────────────────── @@ -612,17 +613,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO; final BigDecimal totalWithdrawal = faceValue.add(effectiveCharges).add(effectiveGst); + // A book debit, not a cash withdrawal: it posts no journal entry of its own, so the one + // DD entry below (Dr control / Cr DD Payable + charges + GST) is the only posting. A cash + // withdrawal posted Dr control / Cr Cash-at-Vault for cash that never moved, which then + // had to be hidden by flagging it reversed. if (savingsAccountId != null) { - final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); - resultingSavingsTransactionId = Long.valueOf(txnId); + resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId()); drGlAccountId = getSavingsControlGlId(savingsAccountId); - markWithdrawalGlEntriesReversed("S" + txnId); linkedAccountId = savingsAccountId; } else { - final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); - resultingCurrentTransactionId = Long.valueOf(txnId); + resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); - markWithdrawalGlEntriesReversed("CAT" + txnId); linkedAccountId = currentAccountId; } if (drGlAccountId == null) { @@ -911,17 +914,16 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI Long linkedAccountId = null; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; + // Book debit, no journal entry of its own -- see approveIssueRequest(). if (savingsAccountId != null) { - final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); - resultingSavingsTransactionId = Long.valueOf(txnId); + resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId()); drGlAccountId = getSavingsControlGlId(savingsAccountId); - markWithdrawalGlEntriesReversed("S" + txnId); linkedAccountId = savingsAccountId; } else { - final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); - resultingCurrentTransactionId = Long.valueOf(txnId); + resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); - markWithdrawalGlEntriesReversed("CAT" + txnId); linkedAccountId = currentAccountId; } if (drGlAccountId == null) { @@ -1028,9 +1030,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId, final BigDecimal ddLimitAmount) { - if (ddPayableGlAccountId == null) { - throw new IllegalArgumentException("Sponsor DD Payable GL account is required."); - } + // DD Payable GL is no longer configured on this screen -- it lives on each sponsor bank + // (m_sponsor_bank.dd_payable_gl_account_id) and is the only source DD posting actually reads + // (see findDdPayableGlAccountIdFor*). The column here is kept nullable for backward + // compatibility but must never be required, or a tenant that has never opened this dialog + // (payable NULL in the row) cannot save the charges/GST/cash mapping at all. final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18); final List> existing = this.jdbcTemplate.queryForList( @@ -1055,7 +1059,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "gst_payable_gl_account_id, dd_limit_amount, created_date) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); - ps.setLong(1, ddPayableGlAccountId); + if (ddPayableGlAccountId != null) ps.setLong(1, ddPayableGlAccountId); else ps.setNull(1, java.sql.Types.BIGINT); if (chequeClearingGlAccountId != null) ps.setLong(2, chequeClearingGlAccountId); else ps.setNull(2, java.sql.Types.BIGINT); if (cashGlAccountId != null) ps.setLong(3, cashGlAccountId); else ps.setNull(3, java.sql.Types.BIGINT); if (chargesIncomeGlAccountId != null) ps.setLong(4, chargesIncomeGlAccountId); else ps.setNull(4, java.sql.Types.BIGINT); @@ -1582,21 +1586,6 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return result.getResourceId().toString(); } - /** - * A savings/current withdrawal auto-creates its own Dr SavingsControl / Cr CashVault journal entry — - * but the DD money isn't leaving as physical cash, it's becoming a liability to the sponsor bank. - * Hides that auto-created entry from GL reports (mirrors the same technique the Clerk Disbursement - * module uses for its own "Collection" flow) so only our combined Dr SavingsControl / Cr Sponsor DD - * Payable entry, posted via postGlOffsetEntry() right after this call, is visible. - */ - private void markWithdrawalGlEntriesReversed(final String transactionId) { - // Guarded by reversed = false: if transaction_id text is ever reused, this can only flip - // rows that are currently live, never re-touch an already-reversed unrelated transaction. - this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", - transactionId); - } - /** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */ private Long getSavingsControlGlId(final Long savingsAccountId) { final List> rows = this.jdbcTemplate.queryForList( @@ -1612,7 +1601,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final List> rows = this.jdbcTemplate.queryForList( "SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java new file mode 100644 index 000000000..8b9eccb6f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java @@ -0,0 +1,383 @@ +/** + * 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.tds.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Admin screen backing API for TDS on deposit interest: the thresholds, rates and statutory + * references for each financial year, and the GL account deducted tax is credited to. + * + * Every TDS screen computes from what this returns. Nothing about the tax is compiled into the + * frontend any more, so a year that is not configured here is a year the screens refuse to compute + * rather than one they compute on stale figures. + */ +@Path("/v1/tdsconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class TdsConfigApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "TDSCONFIG"; + + private static final Pattern FY_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})$"); + + private static final List REGIMES = List.of("ITA_1961", "ITA_2025"); + private static final List DECLARATION_FORMS = List.of("15G", "15H", "121"); + private static final List FREQUENCIES = List.of("MONTHLY", "QUARTERLY"); + + /** GL roles, in the order the screen presents them. */ + private static final Map GL_ROLES = new LinkedHashMap<>(); + static { + GL_ROLES.put("TDS_PAYABLE", new String[] { "TDS payable", + "Liability account that tax deducted from deposit interest is credited to, and held in until it is " + + "remitted to the Government." }); + } + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── read ─────────────────────────── + + /** + * Open to any signed-in user, not just holders of READ_TDSCONFIG: a teller's Form 121 tab and the + * branch TDS reports compute from these figures. Only the admin screen is gated on the permission. + */ + @GET + public String retrieveAll() { + this.context.authenticatedUser(); + final Map out = new LinkedHashMap<>(); + out.put("years", years()); + out.put("glRoles", glRoles()); + return gson.toJson(out); + } + + /** Liability detail accounts, the only kind tax payable can sit in. */ + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code + FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 AND classification_enum = 2 + ORDER BY gl_code + """); + final List> options = new ArrayList<>(); + for (final Map a : accounts) { + final Map o = new LinkedHashMap<>(); + o.put("id", a.get("id")); + o.put("name", a.get("name")); + o.put("glCode", a.get("gl_code")); + options.add(o); + } + final Map out = new LinkedHashMap<>(); + out.put("glAccountOptions", options); + out.put("regimes", REGIMES); + out.put("declarationForms", DECLARATION_FORMS); + out.put("frequencies", FREQUENCIES); + return gson.toJson(out); + } + + private List> years() { + final List> rows = jdbcTemplate + .queryForList("SELECT * FROM m_tds_fy_config ORDER BY fy_label DESC"); + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("fy", r.get("fy_label")); + o.put("regime", r.get("regime")); + o.put("interestTdsSection", r.get("interest_tds_section")); + o.put("thresholdSection", r.get("threshold_section")); + o.put("noPanSection", r.get("no_pan_section")); + o.put("defaultSection", r.get("default_section")); + o.put("declarationForm", r.get("declaration_form")); + o.put("declarationRule", r.get("declaration_rule")); + o.put("declarationSection", r.get("declaration_section")); + o.put("tdsStatementForm", r.get("tds_statement_form")); + o.put("tdsStatementRule", r.get("tds_statement_rule")); + o.put("certificateForm", r.get("certificate_form")); + o.put("declarationStatementFrequency", r.get("declaration_statement_frequency")); + o.put("declarationStatementDueDay", r.get("declaration_statement_due_day")); + o.put("thresholdGeneral", r.get("threshold_general")); + o.put("thresholdSenior", r.get("threshold_senior")); + o.put("ratePanPercentage", r.get("rate_pan_percentage")); + o.put("rateNoPanPercentage", r.get("rate_no_pan_percentage")); + o.put("basicExemption", r.get("basic_exemption")); + o.put("nilTaxCeiling", r.get("nil_tax_ceiling")); + o.put("roundTo", r.get("round_to")); + o.put("lateDeductionPercentage", r.get("late_deduction_percentage")); + o.put("lateDepositPercentage", r.get("late_deposit_percentage")); + o.put("seedDefault", r.get("is_seed_default")); + o.put("updatedOn", r.get("updated_on") == null ? null : String.valueOf(r.get("updated_on"))); + out.add(o); + } + return out; + } + + /** Every role, including one with nothing mapped - the screen must show the gap. */ + private List> glRoles() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.config_key, c.gl_account_id, a.name AS gl_account_name, a.gl_code + FROM m_tds_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + """); + final Map> byKey = new LinkedHashMap<>(); + for (final Map r : rows) { + byKey.put((String) r.get("config_key"), r); + } + final List> out = new ArrayList<>(); + for (final Map.Entry role : GL_ROLES.entrySet()) { + final Map o = new LinkedHashMap<>(); + o.put("configKey", role.getKey()); + o.put("label", role.getValue()[0]); + o.put("description", role.getValue()[1]); + final Map mapped = byKey.get(role.getKey()); + o.put("glAccountId", mapped == null ? null : mapped.get("gl_account_id")); + o.put("glCode", mapped == null ? null : mapped.get("gl_code")); + o.put("glAccountName", mapped == null ? null : mapped.get("gl_account_name")); + out.add(o); + } + return out; + } + + // ─────────────────────────── write ─────────────────────────── + + /** + * Creates or replaces one financial year. The whole row is sent, so a save never leaves a year + * half old and half new. Saving clears the seed flag: the figures are now this bank's own. + */ + @PUT + @Path("year/{fy}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String upsertYear(@PathParam("fy") final String fy, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + validateFy(fy); + + final JsonElement parsed = this.fromJsonHelper.parse(body); + final JsonObject root = parsed.getAsJsonObject(); + + final String regime = oneOf(root, "regime", REGIMES); + final String declarationForm = oneOf(root, "declarationForm", DECLARATION_FORMS); + final String frequency = oneOf(root, "declarationStatementFrequency", FREQUENCIES); + final int dueDay = integer(root, "declarationStatementDueDay", 1, 31); + final int roundTo = integer(root, "roundTo", 1, 100); + + final BigDecimal thresholdGeneral = amount(root, "thresholdGeneral"); + final BigDecimal thresholdSenior = amount(root, "thresholdSenior"); + final BigDecimal basicExemption = amount(root, "basicExemption"); + final BigDecimal nilTaxCeiling = amount(root, "nilTaxCeiling"); + final BigDecimal ratePan = percentage(root, "ratePanPercentage"); + final BigDecimal rateNoPan = percentage(root, "rateNoPanPercentage"); + final BigDecimal lateDeduction = percentage(root, "lateDeductionPercentage"); + final BigDecimal lateDeposit = percentage(root, "lateDepositPercentage"); + + // The no-PAN rate is a penalty rate. Below the ordinary rate it would reward a missing PAN. + if (rateNoPan.compareTo(ratePan) < 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.nopan.rate.below.rate", + "The rate without PAN cannot be lower than the rate with PAN."); + } + + final Object[] values = { regime, text(root, "interestTdsSection"), text(root, "thresholdSection"), + text(root, "noPanSection"), text(root, "defaultSection"), declarationForm, text(root, "declarationRule"), + text(root, "declarationSection"), text(root, "tdsStatementForm"), text(root, "tdsStatementRule"), + text(root, "certificateForm"), frequency, dueDay, thresholdGeneral, thresholdSenior, ratePan, rateNoPan, + basicExemption, nilTaxCeiling, roundTo, lateDeduction, lateDeposit, this.context.authenticatedUser().getId(), + fy }; + + final int updated = jdbcTemplate.update(""" + UPDATE m_tds_fy_config + SET regime = ?, interest_tds_section = ?, threshold_section = ?, no_pan_section = ?, + default_section = ?, declaration_form = ?, declaration_rule = ?, declaration_section = ?, + tds_statement_form = ?, tds_statement_rule = ?, certificate_form = ?, + declaration_statement_frequency = ?, declaration_statement_due_day = ?, + threshold_general = ?, threshold_senior = ?, rate_pan_percentage = ?, + rate_no_pan_percentage = ?, basic_exemption = ?, nil_tax_ceiling = ?, round_to = ?, + late_deduction_percentage = ?, late_deposit_percentage = ?, + is_seed_default = false, updated_on = now(), updated_by = ? + WHERE fy_label = ? + """, values); + if (updated == 0) { + jdbcTemplate.update(""" + INSERT INTO m_tds_fy_config + (regime, interest_tds_section, threshold_section, no_pan_section, default_section, + declaration_form, declaration_rule, declaration_section, tds_statement_form, + tds_statement_rule, certificate_form, declaration_statement_frequency, + declaration_statement_due_day, threshold_general, threshold_senior, rate_pan_percentage, + rate_no_pan_percentage, basic_exemption, nil_tax_ceiling, round_to, + late_deduction_percentage, late_deposit_percentage, created_by, fy_label, + is_seed_default, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, now()) + """, values); + } + return retrieveAll(); + } + + /** + * Removes one year. The last year is kept: with none at all every TDS screen stops computing, + * which is a harsher outcome than an administrator deleting the wrong row expects. + */ + @DELETE + @Path("year/{fy}") + public String deleteYear(@PathParam("fy") final String fy) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tds_fy_config", Integer.class); + if (total != null && total <= 1) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.last.year", + "The only financial year on record cannot be removed - edit it instead."); + } + final int removed = jdbcTemplate.update("DELETE FROM m_tds_fy_config WHERE fy_label = ?", fy); + if (removed == 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.year.not.found", + "Financial year " + fy + " is not configured."); + } + return retrieveAll(); + } + + /** Sets, or with a null account clears, the GL account behind one role. */ + @PUT + @Path("gl/{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateGlRole(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + if (!GL_ROLES.containsKey(configKey)) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.unknown.role", + "'" + configKey + "' is not a known TDS GL role."); + } + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + final Long accountId = root.has("glAccountId") && !root.get("glAccountId").isJsonNull() + ? root.get("glAccountId").getAsLong() + : null; + + if (accountId == null) { + jdbcTemplate.update("DELETE FROM m_tds_gl_config WHERE config_key = ?", configKey); + return retrieveAll(); + } + + final Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_gl_account + WHERE id = ? AND disabled = false AND account_usage = 1 AND classification_enum = 2 + """, Integer.class, accountId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.gl.account.invalid", + "GL account " + accountId + " was not found, is disabled, is a header, or is not a liability account."); + } + final Long userId = this.context.authenticatedUser().getId(); + final int updated = jdbcTemplate.update( + "UPDATE m_tds_gl_config SET gl_account_id = ?, updated_on = now(), updated_by = ? WHERE config_key = ?", + accountId, userId, configKey); + if (updated == 0) { + jdbcTemplate.update( + "INSERT INTO m_tds_gl_config (config_key, gl_account_id, updated_on, updated_by) VALUES (?, ?, now(), ?)", + configKey, accountId, userId); + } + return retrieveAll(); + } + + // ─────────────────────────── validation ─────────────────────────── + + /** '2026-27': a four-digit opening year followed by the next year's last two digits. */ + private static void validateFy(final String fy) { + final Matcher m = fy == null ? null : FY_PATTERN.matcher(fy); + if (m == null || !m.matches() || (Integer.parseInt(m.group(1)) + 1) % 100 != Integer.parseInt(m.group(2))) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.fy.invalid", + "Financial year must look like 2026-27."); + } + } + + private static String text(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull()) { + return null; + } + final String value = root.get(name).getAsString().trim(); + return value.isEmpty() ? null : value; + } + + private static String oneOf(final JsonObject root, final String name, final List allowed) { + final String value = text(root, name); + if (value == null || !allowed.contains(value)) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid", + name + " must be one of " + allowed + "."); + } + return value; + } + + private static BigDecimal number(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull() || root.get(name).getAsString().isBlank()) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".required", name + " is required."); + } + try { + return root.get(name).getAsBigDecimal(); + } catch (NumberFormatException e) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid", name + " must be a number."); + } + } + + private static BigDecimal amount(final JsonObject root, final String name) { + final BigDecimal value = number(root, name); + if (value.signum() < 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".negative", name + " cannot be negative."); + } + return value; + } + + private static BigDecimal percentage(final JsonObject root, final String name) { + final BigDecimal value = number(root, name); + if (value.signum() < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range", + name + " must be between 0 and 100."); + } + return value; + } + + private static int integer(final JsonObject root, final String name, final int min, final int max) { + final BigDecimal value = number(root, name); + if (value.stripTrailingZeros().scale() > 0 || value.intValue() < min || value.intValue() > max) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range", + name + " must be a whole number between " + min + " and " + max + "."); + } + return value.intValue(); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 9813f48f7..b8e38a310 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -120,4 +120,17 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml new file mode 100644 index 000000000..a9bcd6109 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_rate + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml new file mode 100644 index 000000000..0f630d12a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OSSRETURN' + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code = 'READ_OSSRETURN' + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'READ_OSSRETURN' + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml new file mode 100644 index 000000000..fdcbd37d7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml new file mode 100644 index 000000000..5abce7bf0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_rbi_reserve_gl_config + SET liability_class = 'TIME' + WHERE config_key = 'DEMAND_TIME_LIABILITIES' + AND liability_class IS NULL + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = 'SAVINGS_DEMAND' + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml new file mode 100644 index 000000000..3f436b0c6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE m_rbi_reserve_role + ADD CONSTRAINT ck_rbi_reserve_role_usage + CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'MEMO')) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml new file mode 100644 index 000000000..c7584302c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml new file mode 100644 index 000000000..95a32eb2d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml new file mode 100644 index 000000000..09bf7ed47 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_CODE', 'READ_CODEVALUE') + AND LOWER(r.name) LIKE '%cashier%' + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml new file mode 100644 index 000000000..aadb1bc9e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + UPDATE acc_product_mapping SET product_type = 6 + WHERE id IN (SELECT id FROM ( + SELECT m.id FROM acc_product_mapping m + WHERE m.product_type = 2 + AND m.product_id IN (SELECT cp.id FROM m_current_product cp) + AND m.product_id NOT IN (SELECT sp.id FROM m_savings_product sp)) x) + + + + + + + + + + + + UPDATE acc_product_mapping SET product_type = 6 + WHERE id IN (SELECT id FROM ( + SELECT m.id FROM acc_product_mapping m + WHERE m.product_type = 2 + AND m.product_id IN (SELECT cp.id FROM m_current_product cp) + AND m.product_id IN (SELECT sp.id FROM m_savings_product sp) + AND m.financial_account_type <> 26 + AND m.id > (SELECT MAX(g.min_id) FROM ( + SELECT d.product_id, d.financial_account_type, MIN(d.id) AS min_id + FROM acc_product_mapping d + WHERE d.product_type = 2 AND d.payment_type IS NULL AND d.charge_id IS NULL + GROUP BY d.product_id, d.financial_account_type + HAVING COUNT(*) > 1) g + WHERE g.product_id = m.product_id)) x) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml new file mode 100644 index 000000000..499aa2d59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + SELECT COUNT(*) FROM ( + SELECT 1 FROM acc_product_mapping + WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL + AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL + AND write_off_reason_id IS NULL + GROUP BY product_type, product_id, financial_account_type + HAVING COUNT(*) > 1) d + + + + + CREATE UNIQUE INDEX uq_acc_product_mapping_core + ON acc_product_mapping (product_type, product_id, financial_account_type) + WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL + AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL + AND write_off_reason_id IS NULL + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml new file mode 100644 index 000000000..eb135588b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml @@ -0,0 +1,386 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_MEMO_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_ANNEX%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml new file mode 100644 index 000000000..f6e04242e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_tds_fy_config + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java index 772c96b2a..b9dee877d 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java @@ -49,8 +49,10 @@ public class SavingsProductAccountingDataValidator { .notNull().integerGreaterThanZero(); } + // A current product maps no cash GL: its deposits and withdrawals post cash to the branch cash GL + // (Financial Activity mapping), so Savings Reference is validated there only when supplied. if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element) - || ignoreExistenceValidation) { + || (ignoreExistenceValidation && !accountType.equals(DepositAccountType.CURRENT_DEPOSIT))) { final Long savingsReferenceAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()) @@ -64,8 +66,11 @@ public class SavingsProductAccountingDataValidator { baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) .value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); } + // A current account pays no interest, so its product has no Interest On Savings expense account: + // validated there only when actually supplied. + final boolean paysInterest = !accountType.equals(DepositAccountType.CURRENT_DEPOSIT); if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element) - || ignoreExistenceValidation) { + || (ignoreExistenceValidation && paysInterest)) { final Long interestOnSavingsAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue()) 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 index 0f3e8922b..1755d4a76 100644 --- 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 @@ -20,6 +20,7 @@ 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; @@ -36,4 +37,19 @@ public class CurrentOnlyEditFields { @Column(name = "od_account_closed_zero_balance_in_day_closing") private Boolean odAccountClosedZeroBalanceInDayClosing; + + @Column(name = "od_min_limit") + private BigDecimal odMinLimit; + + @Column(name = "od_security_type") + private String odSecurityType; + + @Column(name = "od_excess_allowed") + private Boolean odExcessAllowed; + + @Column(name = "od_excess_limit_percent") + private BigDecimal odExcessLimitPercent; + + @Column(name = "od_penal_rate") + private BigDecimal odPenalRate; } 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 index 04534c5ac..3043ceff6 100644 --- 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 @@ -771,6 +771,26 @@ public final class ProductPendingEditFieldMapper { f.setOdAccountClosedZeroBalanceInDayClosing(command.booleanObjectValueOfParameterNamed("odAccountClosedZeroBalanceInDayClosing")); present.add("odAccountClosedZeroBalanceInDayClosing"); } + if (command.hasParameter("odMinLimit")) { + f.setOdMinLimit(command.bigDecimalValueOfParameterNamed("odMinLimit")); + present.add("odMinLimit"); + } + if (command.hasParameter("odSecurityType")) { + f.setOdSecurityType(command.stringValueOfParameterNamed("odSecurityType")); + present.add("odSecurityType"); + } + if (command.hasParameter("odExcessAllowed")) { + f.setOdExcessAllowed(command.booleanObjectValueOfParameterNamed("odExcessAllowed")); + present.add("odExcessAllowed"); + } + if (command.hasParameter("odExcessLimitPercent")) { + f.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed("odExcessLimitPercent")); + present.add("odExcessLimitPercent"); + } + if (command.hasParameter("odPenalRate")) { + f.setOdPenalRate(command.bigDecimalValueOfParameterNamed("odPenalRate")); + present.add("odPenalRate"); + } return present; } @@ -781,6 +801,11 @@ public final class ProductPendingEditFieldMapper { putString(root, submitted, "avgBalanceCalcPeriod", f.getAvgBalanceCalcPeriod()); putString(root, submitted, "validForPeriod", f.getValidForPeriod()); putBoolean(root, submitted, "odAccountClosedZeroBalanceInDayClosing", f.getOdAccountClosedZeroBalanceInDayClosing()); + putNumber(root, submitted, "odMinLimit", f.getOdMinLimit()); + putString(root, submitted, "odSecurityType", f.getOdSecurityType()); + putBoolean(root, submitted, "odExcessAllowed", f.getOdExcessAllowed()); + putNumber(root, submitted, "odExcessLimitPercent", f.getOdExcessLimitPercent()); + putNumber(root, submitted, "odPenalRate", f.getOdPenalRate()); } public static Set populateShareOnlyFields(final JsonCommand command, final ShareOnlyEditFields f) { 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 95f04b653..b90805f5e 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 @@ -55,4 +55,5 @@ + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml new file mode 100644 index 000000000..81efbf517 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f696499102747366be04508f2b0015f5ca8b046a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 18:09:13 +0530 Subject: [PATCH 07/39] fix(current-account): stop account-id collisions between savings and current accounts from misrouting money and losing GL entries m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can be a real row in both -- an id-only lookup used to decide "is this a current account?" once sent a real deposit into an unrelated client's current account instead of the savings account a request actually named, across every entry point that did this: cashier deposit approval/reprocess, clerk withdrawal approval, and clerk disbursement collection/disbursement/bank-transfer. All of those now also verify the account_no before trusting an id match. Separately, GL posting for current-account transactions has been silently failing since acc_product_mapping had two rows for the same (product_id, product_type, financial_account_type) slot -- current products share PortfolioProductType.SAVING's id space with real savings products, and a NULL payment_type doesn't collide under that column's own unique constraint, so duplicates went unnoticed. The single-result repository lookup that read this mapping threw on the duplicate, which was swallowed by a try/catch, so account balances kept updating while no journal entry was ever posted. Both current-account GL lookups now take the most recently created mapping instead of failing outright. Co-Authored-By: Claude Sonnet 5 --- .../api/CashierDepositRequestApiResource.java | 43 ++++++++++++++++--- .../ClerkDisbursementRequestApiResource.java | 39 +++++++++++++---- .../ClerkWithdrawalRequestApiResource.java | 21 +++++++-- 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 25b268f90..592a66d32 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -423,7 +423,13 @@ public class CashierDepositRequestApiResource { // even for current accounts (submitted before the frontend fix), and FD/RD accounts share // the very same m_savings_account table as a plain savings account, distinguished only by // deposit_type_enum -- so this is looked up fresh rather than trusted from the request. - final String depositCategory = resolveDepositAccountCategory(savingsAccountId); + // The account_no is also checked -- m_current_account and m_savings_account each keep + // their own identity sequence, so the same numeric id can legitimately belong to a + // current account AND an unrelated savings account at once. An id match alone once sent + // a real deposit into a stranger's current account instead of the savings account the + // request actually named; account_no is the one value that can't collide between them. + final String requestAccountNo = (String) record.get("account_no"); + final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo); final boolean isCurrentAccount = "CURRENT".equals(depositCategory); txnId = switch (depositCategory) { case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description); @@ -578,7 +584,8 @@ public class CashierDepositRequestApiResource { final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null; final String description = (String) record.get("description"); - if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) { + final String requestAccountNo = (String) record.get("account_no"); + if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) { throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits."); } @@ -863,11 +870,30 @@ public class CashierDepositRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } - /** Returns true if this account ID belongs to m_current_account (not m_savings_account). */ - private boolean isCurrentAccountById(final Long accountId) { + /** + * Returns true if this account ID belongs to m_current_account (not m_savings_account). + * + * m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say) + * can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two + * different clients, sharing nothing but a coincidental number. An id-only check here once sent + * a savings deposit into a stranger's current account: id=1 existed in m_current_account, so + * this returned true, even though the request was for m_savings_account id=1. + * + * expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is + * only trusted once the account_no on that current-account row confirms it is truly the same + * account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller + * has no account_no to compare against, e.g. an id already known to be unambiguous). + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank()) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; @@ -880,12 +906,15 @@ public class CashierDepositRequestApiResource { * m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a * plain "not current → savings" check would post an FD/RD deposit through the generic savings * deposit command, which does not accept a term-deposit account id. + * + * expectedAccountNo guards the current-account check against an id collision with + * m_savings_account — see {@link #isCurrentAccountById}. */ - private String resolveDepositAccountCategory(final Long accountId) { + private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) { if (accountId == null) { return "SAVINGS"; } - if (isCurrentAccountById(accountId)) { + if (isCurrentAccountById(accountId, expectedAccountNo)) { return "CURRENT"; } final List depositTypes = jdbcTemplate.queryForList( diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index b8cc344d9..b94837355 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -551,8 +551,11 @@ public class ClerkDisbursementRequestApiResource { final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString()); final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?"; final Long accountId = entry.get("savingsAccountId").getAsLong(); - final boolean isCurrent = "CURRENT".equals( - entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"); + // Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can + // exist in both m_current_account and m_savings_account at once, and a bank + // transfer posts and moves money immediately, with no maker-checker step to catch + // a wrong label before it does. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -660,7 +663,7 @@ public class ClerkDisbursementRequestApiResource { } final Long accountId = entry.get("savingsAccountId").getAsLong(); - final boolean isCurrentAccount = isCurrentAccountById(accountId); + final boolean isCurrentAccount = isCurrentAccountById(accountId, acNo); String txnId = null; if ("Cr".equals(dbCr)) { @@ -959,8 +962,11 @@ public class ClerkDisbursementRequestApiResource { final Long accountId = e.get("savingsAccountId").getAsLong(); final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; - final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; - final boolean isCurrent = "CURRENT".equals(acTyp); + // Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can + // exist in both m_current_account and m_savings_account at once, and a bank transfer + // posts and moves money immediately, with no maker-checker step to catch a wrong + // label before it does. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -986,8 +992,8 @@ public class ClerkDisbursementRequestApiResource { final Long accountId = e.get("savingsAccountId").getAsLong(); final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; - final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; - final boolean isCurrent = "CURRENT".equals(acTyp); + // Not trusted from entry.accountType alone -- see isCurrentAccountById. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -1361,10 +1367,25 @@ public class ClerkDisbursementRequestApiResource { commandsSourceWritePlatformService.logCommandSource(cmd); } - private boolean isCurrentAccountById(final Long accountId) { + /** + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both at once -- two unrelated accounts, possibly two different + * clients, sharing nothing but a coincidental number. An id-only check here has already sent a + * real deposit into a stranger's current account instead of the savings account the entry + * actually named. expectedAccountNo -- the entry's own accountNo -- closes that gap: an id match + * is only trusted once the account_no on that current-account row confirms it. Pass null to skip + * the check where the caller has no account_no to compare against. + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank() || "?".equals(expectedAccountNo)) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 8e2891297..57f9cb070 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -470,7 +470,7 @@ public class ClerkWithdrawalRequestApiResource { String txnId = null; if (savingsAccountId != null) { - final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); + final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no")); txnId = isCurrentAccount ? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description) : executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description); @@ -872,10 +872,25 @@ public class ClerkWithdrawalRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } - private boolean isCurrentAccountById(final Long accountId) { + /** + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both at once -- two unrelated accounts, possibly two different + * clients, sharing nothing but a coincidental number. An id-only check here can send a real + * withdrawal against a stranger's current account instead of the savings account the request + * actually named. expectedAccountNo -- the request's own account_no -- closes that gap: an id + * match is only trusted once the account_no on that current-account row confirms it. Pass null + * to skip the check where the caller has no account_no to compare against. + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank()) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; From 32411c924b2f011762d9ac906c0c67227eb81bef Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 10:58:44 +0530 Subject: [PATCH 08/39] fix(accounting): stop the Journal Entry view showing a colliding current account's number for a savings GL entry entity_type_enum=2 (PortfolioProductType.SAVING) is written for both savings and current-account journal entries -- current products deliberately share SAVING's GL type space -- so it can't distinguish them, and m_savings_account/m_current_account each have their own id sequence, meaning an entity_id can be a real row in both. The account- number column's COALESCE(ca.account_no, sa.account_no) always preferred the current account's number whenever both matched, so a savings deposit's own journal entry displayed an unrelated current account's number instead of the account it actually posted against. savings_transaction_id is the one reliable signal available on this query: only a genuine savings transaction sets it. Checking it first resolves the correct account whenever it's present, and falls back to the previous behavior only when it's absent (current-account entries, which don't set it). Co-Authored-By: Claude Sonnet 5 --- .../service/JournalEntryReadPlatformServiceImpl.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java index 10fe6b8b8..8115f66ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java @@ -105,7 +105,18 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ") .append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append(" eao.external_id as externalAssetOwner, ") + // entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and + // current-account entries -- current products deliberately share SAVING's GL type + // space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two + // apart on its own, and m_savings_account/m_current_account each have their own id + // sequence: an entity_id can be a real row in both, making a plain + // COALESCE(ca.account_no, sa.account_no) pick the current account's number even for + // an entry that is genuinely a savings one. savings_transaction_id is the one + // unambiguous signal available here -- only a real savings transaction sets it -- so + // it decides first; only when it's absent (a current-account entry, or a savings + // entry with no linked transaction row) does the current-account guess apply. .append(" CASE ") + .append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ") .append(" ELSE NULL ") .append(" END as savingsAccountNo "); From ea39c8e82a9c1b457cc9fd0b482b2560c9c51c5d Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 11:22:31 +0530 Subject: [PATCH 09/39] fix(current-account): recognize Withdrawn and Rejected as terminal when checking for a duplicate product application countActiveByClientAndProduct only excluded status 600 (CLOSED), so a client whose prior application for the same product was Withdrawn (400) or Rejected (500) -- both terminal, per CurrentAccount.withdraw()/ reject() -- was still blocked from applying again, same as if that account were genuinely still open. Only 100/200/300 (submitted, approved, active) should collide with a fresh application. Co-Authored-By: Claude Sonnet 5 --- .../currentaccount/domain/CurrentAccountRepository.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java index 4c5617f97..944afff22 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java @@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository findActiveByClientId(@Param("clientId") Long clientId); - @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600") + // statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior + // account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw() + // /.reject(), are equally terminal and were still being counted as "still has one", blocking a + // fresh application the client should have been free to make. 100/200/300 (submitted, approved, + // active) are the only statuses a second application on the same product should actually collide + // with. + @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)") long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId); } From 8b2fb052d49509808491c360e054ed04a89e34e7 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:21 +0530 Subject: [PATCH 10/39] fix(fd): keep a genuinely-matured FD on the Dashboard instead of dropping it The maturity-dashboard query only ever selected ACTIVE(300) accounts. That was fine while nothing ever really reached MATURED(800) in this dev setup (test dates were only SQL-backdated, never run through the real nightly job), but the first time the job actually flipped an account's real status, it silently vanished from the ladder instead of showing under "Matured" -- exactly the state the ladder exists to surface. Now selects ACTIVE or MATURED. Co-Authored-By: Claude Sonnet 5 --- .../FixedDepositMaturityDashboardApiResource.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java index c1a1c8b51..9ab7d736f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java @@ -33,8 +33,12 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; /** - * FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder - * needs (account/client/product identity, maturity date, deposit and maturity amounts). + * FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the + * real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no + * closure instruction to auto-process -- with just the fields the maturity ladder needs + * (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays + * here until it's actually renewed or closed, since that's exactly the "needs manual attention" + * state the ladder exists to surface. * * Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that * endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency, @@ -57,6 +61,8 @@ public class FixedDepositMaturityDashboardApiResource { private static final int FIXED_DEPOSIT = 200; /** SavingsAccountStatusType.ACTIVE. */ private static final int ACTIVE = 300; + /** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */ + private static final int MATURED = 800; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; @@ -87,9 +93,9 @@ public class FixedDepositMaturityDashboardApiResource { JOIN m_savings_product sp ON sp.id = sa.product_id LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id LEFT JOIN m_client c ON c.id = sa.client_id - WHERE sa.deposit_type_enum = ? AND sa.status_enum = ? + WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) ORDER BY datp.maturity_date ASC - """, FIXED_DEPOSIT, ACTIVE); + """, FIXED_DEPOSIT, ACTIVE, MATURED); return this.gson.toJson(rows); } From 3fb008a22a846de0ebf95b1221a1230a40de564f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:28 +0530 Subject: [PATCH 11/39] fix(products): clearer error message for an FD product amount field that's too large The FD product's exception mapper fell back to a bare "Unknown data integrity issue with savings product." for any DB error it didn't recognize, with no detail -- unlike its sibling class for regular Savings products, which at least appends the underlying error. Added a specific, actionable message for the numeric-overflow case (an amount field entered too large for its column), and made the generic fallback include the real detail too. Co-Authored-By: Claude Sonnet 5 --- ...dDepositProductWritePlatformServiceJpaRepositoryImpl.java | 5 +++++ 1 file changed, 5 insertions(+) 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 301e3bbcf..053998bf9 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 @@ -402,8 +402,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements msg = "Savings product with short name `" + shortName + "` already exists"; param = "shortName"; msgArgs = new Object[] { shortName, dae }; + } else if (checkEx.getMessage().contains("numeric field overflow")) { + msgCode += ".amount.too.large"; + msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again."; + msgArgs = new Object[] { dae }; } else { msgCode += ".unknown.data.integrity.issue"; + msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage(); msgArgs = new Object[] { dae }; } log.error("Error occured.", dae); From 331c55316439a3e907eb74ed36e9714e8c2a2def Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:46 +0530 Subject: [PATCH 12/39] fix(accounting): interest postings were never reaching the general ledger createInterestPostingJournalEntriesForSavings() documents a 3-stage posting (Dr Interest On Savings -> Cr Payable, Dr Payable -> Cr Paid, Dr Paid -> Cr Savings Control), but the first stage was never actually coded -- the interest expense account was never even fetched. For a product with no distinct Interest Payable/Paid accounts configured (every product in this system, checked directly), the remaining two stages then collapsed into a same-account no-op that correctly refuses to post, leaving interest silently added to the account's balance with zero GL entries. Affects every deposit type equally (Savings, FD, RD), not just FD -- confirmed by checking every product's accounting mappings. Added the missing stage. Co-Authored-By: Claude Sonnet 5 --- .../service/AccountingProcessorHelper.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index f8235a2a9..b5c375a61 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -651,6 +651,8 @@ public class AccountingProcessorHelper { final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); // The payable comes from the Interest Payable row on the product's Accounting tab, which is // where a bank actually picks it. The reserved name in the product's other GL mappings is @@ -664,13 +666,20 @@ public class AccountingProcessorHelper { final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) .orElse(payableAccount); - // The interest expense is not raised here. It is recognised as the interest accrues, day by - // day, and this posting settles that recognised debt - raising the expense again at payout - // would put the same interest on the P&L twice. This mirrors the pooled posting service, - // which pays a product's whole membership the same way; a closure reaching this path must - // produce the same entries as the nightly run, or the two disagree on what one payment did. + // Stage 1: the expense is recognised against whatever the liability sits in right now -- the + // named Payable account if the product has one, or straight against Savings Control if it + // doesn't. This was missing entirely: every product, configured or not, posted zero journal + // entries for interest until this leg existed, since a product with no Payable/Paid names + // configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else + // to post in their place. + postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // The interest expense is not raised again here. It was just raised above, and this posting + // only settles that recognised debt onward to where it is actually paid out from -- raising + // the expense a second time would put the same interest on the P&L twice. // - // Stage 1: the liability moves from the payable to the account it is paid out of. Where the + // Stage 2: the liability moves from the payable to the account it is paid out of. Where the // product names no paid account both resolve to the payable and this says nothing, so it is // skipped and the payout comes straight out of the payable. final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); @@ -679,7 +688,7 @@ public class AccountingProcessorHelper { isReversal); } - // Stage 2: into the member's own balance. + // Stage 3: into the member's own balance. final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); From 2c49d6f63766165194ca84022c5d22a3dbdab900 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:55 +0530 Subject: [PATCH 13/39] feat(fd): add read-only renewal preview endpoint, fix stale linked-account reads GET /fixeddepositaccounts/{id}/renewal-preview -- shown in the FD Dashboard's Payment dialog before staff confirm a late renewal, so they see the actual numbers (linked account's rate, gap days, gap-interest amount, projected new balance) instead of confirming blind. Shares its calculation with the posting path (creditGapInterest) via a new computeGapInterestAmount helper, so the previewed number is exactly what gets credited. Also fixes fdAccount.getLinkedAccountId() returning null for an account confirmed to have linked_account_id set in the database -- every caller that needs the FD's Deposit Account Link now reads it fresh via SQL (resolveLinkedAccountId) instead of that stale JPA entity accessor. Co-Authored-By: Claude Sonnet 5 --- ...FixedDepositRenewalPreviewApiResource.java | 60 ++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 140 ++++++++++++++---- .../DepositAccountWritePlatformService.java | 9 ++ 3 files changed, 178 insertions(+), 31 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java new file mode 100644 index 000000000..7c3715caf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; + +/** + * Read-only: what a Renew from the FD Dashboard is about to do, before the staff member confirms + * anything. The Payment dialog calls this on open (dashboardRenewal mode) to show the current + * balance, the linked account's rate, the gap-day count, the computed gap-period interest, and the + * balance the new term will actually start from -- exactly the numbers + * {@code creditGapInterestIfPastGrace} would post, just not posted yet. Nothing here mutates the + * account, so this only needs read permission, unlike its posting sibling. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renewal-preview") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: read-only preview of a Renew action's gap-interest math.") +@RequiredArgsConstructor +public class FixedDepositRenewalPreviewApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("accountId") final Long accountId) { + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.previewFdRenewal(accountId); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 4484134c7..a992682a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1554,7 +1554,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // an account opened straight into Reinvest Principal Only can reach here with none set. // Falls back to the FD's own Deposit Account Link rather than failing the whole job run. if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { - toSavingsId = fdAccount.getLinkedAccountId(); + toSavingsId = resolveLinkedAccountId(fdAccount.getId()); } this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); @@ -1620,7 +1620,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // Only Principal pays the leftover interest out to the FD's own linked Savings account -- the // account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this // should always be present, but fail clearly rather than silently stranding the interest if not. - final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId(); + final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId()); if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) { throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", "This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, " @@ -1630,13 +1630,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // Renewal grace period only shrinks the backdate window below -- it is no longer a precondition // for whether Renew is allowed at all. A product that never configured one behaves as a 0-day // window: renewing on the maturity date itself backdates, any later renewal pays gap interest. - Integer renewalGracePeriodDays = null; - if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { - renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); - } - if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { - renewalGracePeriodDays = 0; - } + final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); final LocalDate maturityDate = fdAccount.maturityDate(); final LocalDate today = DateUtils.getBusinessLocalDate(); @@ -1697,17 +1691,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo "This FD has not matured yet."); } - Integer renewalGracePeriodDays = null; - if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { - renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); - } - if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { - renewalGracePeriodDays = 0; - } - final LocalDate maturityDate = fdAccount.maturityDate(); final LocalDate today = DateUtils.getBusinessLocalDate(); - final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount))); final Map result = new HashMap<>(); result.put("withinGrace", withinGrace); @@ -1715,6 +1701,108 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo return result; } + /** + * Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment + * dialog before the staff member confirms anything -- the actual current balance, the linked + * account's rate, the gap-day count, the computed gap-interest amount, and the balance the FD will + * carry into its new term once that interest lands. Mirrors {@link #creditGapInterest} exactly but + * never posts anything; a within-grace account gets zero for the gap fields since nothing is owed. + */ + @Transactional(readOnly = true) + @Override + public Map previewFdRenewal(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + final Map result = new HashMap<>(); + if (!fdAccount.isMatured()) { + result.put("isMatured", false); + return result; + } + result.put("isMatured", true); + + final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + final BigDecimal currentBalance = fdAccount.getAccountBalance(); + + result.put("maturityDate", maturityDate.toString()); + result.put("renewalGracePeriodDays", renewalGracePeriodDays); + result.put("withinGrace", withinGrace); + result.put("currentBalance", currentBalance); + + if (withinGrace) { + result.put("gapDays", 0L); + result.put("gapInterestAmount", BigDecimal.ZERO); + result.put("projectedBalance", currentBalance); + return result; + } + + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + result.put("gapDays", gapDays); + + final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); + result.put("linkedAccountId", linkedAccountId); + if (linkedAccountId == null) { + // No target to read a rate from -- the caller (the Payment dialog) shows this as a blocking + // "link a Savings account first" message rather than a number, same as the posting path does. + result.put("gapInterestAmount", null); + result.put("projectedBalance", null); + return result; + } + + final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); + final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); + result.put("linkedAccountRate", linkedAccountRate); + result.put("gapInterestAmount", gapInterestAmount); + result.put("projectedBalance", currentBalance.add(gapInterestAmount)); + return result; + } + + /** Null/negative reads as "no grace configured" -- a 0-day window, not a block on renewing. */ + private int resolveRenewalGracePeriodDays(final FixedDepositAccount fdAccount) { + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + return (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) ? 0 : renewalGracePeriodDays; + } + + /** + * The FD's own Deposit Account Link, read fresh via SQL rather than the JPA entity's + * {@code getLinkedAccountId()} -- an account whose link was set at creation through the real + * account-opening command reads back correctly there, but this call site kept returning null for + * an account confirmed to have {@code linked_account_id} set in the database, which every one of + * this method's own gap-interest/reinvest-target callers depends on. Reading it directly sidesteps + * whatever staleness that entity accessor has, rather than trying to chase it down across every + * caller that touches this account within the same request. + */ + private Long resolveLinkedAccountId(final Long savingsAccountId) { + return this.jdbcTemplate.queryForObject("SELECT linked_account_id FROM m_savings_account WHERE id = ?", Long.class, + savingsAccountId); + } + + private BigDecimal resolveLinkedAccountRate(final Long linkedAccountId) { + return this.jdbcTemplate.queryForObject(""" + SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, BigDecimal.class, linkedAccountId); + } + + /** Simple interest on the FD's own current balance, for the stretch between its maturity date and + * today, at the linked account's product rate -- the pure calculation, shared by the posting path + * ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number + * shown to staff before they confirm is exactly the number that gets credited. */ + private BigDecimal computeGapInterestAmount(final FixedDepositAccount fdAccount, final BigDecimal linkedAccountRate, + final long gapDays) { + final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 + : fdAccount.getInterestCalculationDaysInYearType(); + final MathContext mc = MoneyHelper.getMathContext(); + return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc) + .divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP); + } + /** * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its * linked Savings/Current account's product -- credited onto the FD itself as a genuine @@ -1728,25 +1816,15 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo * two this method's sibling endpoint originally supported. */ private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { - final Long linkedAccountId = fdAccount.getLinkedAccountId(); + final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); if (linkedAccountId == null) { throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it " + "before renewing late, so the gap-period interest has a rate to use."); } - final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject(""" - SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa - JOIN m_savings_product sp ON sp.id = sa.product_id - WHERE sa.id = ? - """, BigDecimal.class, linkedAccountId); - + final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); - final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 - : fdAccount.getInterestCalculationDaysInYearType(); - final MathContext mc = MoneyHelper.getMathContext(); - final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc) - .multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc) - .setScale(2, java.math.RoundingMode.HALF_UP); + final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { final Set existingTransactionIds = new HashSet<>(); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index 11b836c37..b588f565e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -114,5 +114,14 @@ public interface DepositAccountWritePlatformService { */ Map creditGapInterestIfPastGrace(Long fdAccountId); + /** + * Read-only preview of what renewing this matured FD right now would do -- shown in the FD + * Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns + * isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace, + * currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountRate, + * gapInterestAmount and projectedBalance (the balance the new term would start from). + */ + Map previewFdRenewal(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); } From 1e903232f042efab0033ca92efd833f1e0c78191 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 14:11:50 +0530 Subject: [PATCH 14/39] fix(fd): pay late-renewal gap interest to the linked Savings account, not the FD Gap-period interest is earned at the linked account's rate, not the FD's -- folding it onto the FD's own balance meant "Re-Invest Maturity Amount" silently carried savings interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" paid it out through the FD's own closure math instead of it already being where it belongs. Now posted directly onto the linked account as its own INTEREST_POSTING transaction; the FD's balance is left untouched, so every closure type renews/transfers its real maturity amount. Renewal Preview updated to match (projectedBalance is now just the FD's own balance) and now also returns the linked account's own account number, so staff can see exactly where the gap interest is about to land before confirming. Co-Authored-By: Claude Sonnet 5 --- ...WritePlatformServiceJpaRepositoryImpl.java | 63 ++++++++++++------- .../DepositAccountWritePlatformService.java | 21 ++++--- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index a992682a9..5f4b58c09 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1704,9 +1704,11 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo /** * Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment * dialog before the staff member confirms anything -- the actual current balance, the linked - * account's rate, the gap-day count, the computed gap-interest amount, and the balance the FD will - * carry into its new term once that interest lands. Mirrors {@link #creditGapInterest} exactly but - * never posts anything; a within-grace account gets zero for the gap fields since nothing is owed. + * account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid + * onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so + * projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews. + * Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets + * zero for the gap fields since nothing is owed. */ @Transactional(readOnly = true) @Override @@ -1750,12 +1752,15 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo result.put("projectedBalance", null); return result; } + result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId)); final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); result.put("linkedAccountRate", linkedAccountRate); result.put("gapInterestAmount", gapInterestAmount); - result.put("projectedBalance", currentBalance.add(gapInterestAmount)); + // Paid onto the linked savings account, not this FD -- what renews is still just the FD's own + // current balance. + result.put("projectedBalance", currentBalance); return result; } @@ -1790,6 +1795,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo """, BigDecimal.class, linkedAccountId); } + /** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} -- + * shown in the Renewal Preview so staff can see exactly which account the gap interest is about + * to land on, not just that "a linked account" exists. */ + private String resolveLinkedAccountNo(final Long linkedAccountId) { + return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId); + } + /** Simple interest on the FD's own current balance, for the stretch between its maturity date and * today, at the linked account's product rate -- the pure calculation, shared by the posting path * ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number @@ -1805,23 +1817,30 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo /** * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its - * linked Savings/Current account's product -- credited onto the FD itself as a genuine - * INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the - * cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD - * Dashboard's generic Payment command) reads the account's balance. Shared by - * {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's - * Renew action now calls the latter as a pre-step before submitting the same generic `payment` - * command the account's own Account Actions menu already uses, so every closure type (Transfer to - * Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the - * two this method's sibling endpoint originally supported. + * linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine + * INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in + * the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at + * the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding + * it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings + * interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay + * it out through the FD's own closure math instead of it already being where it belongs. The FD's + * own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or + * the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap + * interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit} + * and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as + * a pre-step before submitting the same generic `payment` command the account's own Account Actions + * menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial + * Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint + * originally supported. */ private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); if (linkedAccountId == null) { throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", - "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it " - + "before renewing late, so the gap-period interest has a rate to use."); + "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it " + + "before renewing late, so the gap-period interest has somewhere to be paid."); } + final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT); final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); @@ -1829,14 +1848,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { final Set existingTransactionIds = new HashSet<>(); final Set existingReversedTransactionIds = new HashSet<>(); - updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds); - final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(), - today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true); - fdAccount.addTransaction(gapInterestTxn); - fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions()); - this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); - postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount, + linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true); + linkedAccount.addTransaction(gapInterestTxn); + linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions()); + this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount); + postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds); } return gapInterestAmount; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index b588f565e..3c18db4df 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -105,12 +105,15 @@ public interface DepositAccountWritePlatformService { Map renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType); /** - * Credits gap-period interest (simple interest, at the linked Savings/Current account's product - * rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning - * zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the - * generic `payment` command (the same one the account's own Account Actions menu uses), so every - * closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit} - * itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). + * Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto + * that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has + * passed; a no-op, returning zero, if still within it. Paid to the linked account because it was + * earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it + * renews from its real maturity amount regardless of which closure type follows. Called as a + * pre-step before the FD Dashboard's Renew action submits the generic `payment` command (the same + * one the account's own Account Actions menu uses), so every closure type is available from the + * Dashboard, not just the two {@link #renewMaturedFixedDeposit} itself supports. Returns + * {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). */ Map creditGapInterestIfPastGrace(Long fdAccountId); @@ -118,8 +121,10 @@ public interface DepositAccountWritePlatformService { * Read-only preview of what renewing this matured FD right now would do -- shown in the FD * Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns * isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace, - * currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountRate, - * gapInterestAmount and projectedBalance (the balance the new term would start from). + * currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo, + * linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and + * projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually + * renews). */ Map previewFdRenewal(Long fdAccountId); From 87f3e591ce6aee9c51605dde39a6339125b3d89e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 10:34:39 +0530 Subject: [PATCH 15/39] fix(accounting): tag current-account GL entries entity_type_enum=CURRENT instead of reusing SAVING, everywhere this codebase links an entry to its account m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can be a real row in both. Every place that links a GL journal entry back to its account for display -- so the Journal Entries screen shows an Account No instead of a blank "-" -- was tagging entity_type_enum=2 (PortfolioProductType.SAVING) unconditionally, even when the account was actually a current account. Whenever a current account's id collided with an unrelated savings account's id, the entry resolved to the wrong one entirely: a customer's savings deposit could display someone else's current account number, or vice versa. Fixed at the write side, in every place found doing this: - ClerkDisbursementRequestApiResource (Bank Transfer, Collection, Disbursement) -- GlEntry now carries an isCurrentAccount flag, decided once where the account was already resolved, instead of being re-guessed. - ChequeBookIssuanceChargeService, ClearingJournalService, SponsorDDIssueRequestWritePlatformServiceImpl -- same pattern, each already knew savings vs current at the point it linked the entry; that answer is now threaded through instead of discarded. Also fixed on the read side: - JournalEntryReadPlatformServiceImpl: entity_type_enum=6 now resolves unambiguously to the current account; entity_type_enum=2 still falls back to savings_transaction_id (a reliable signal only a genuine savings transaction sets) before guessing, for older rows written before this fix. - JournalEntryReferralServiceImpl (AML referral resolution): the same ambiguity could attribute a suspicious-activity alert to the wrong client entirely -- a same-numbered stranger's account -- when a current account's entity_id collided with a savings account's. Resolved with the same entity_type=6 / savings_transaction_id precedence as the read-side fix. Not touched: JournalEntryAggregationJobReader (year-end aggregation batch job) has the same ambiguity for legacy entity_type_enum=2 rows, but only ever joins m_savings_account -- it has no current-account handling at all, standard-Fineract in origin. Extending it is a real feature addition, not a targeted fix, and touches a scheduled financial batch job this session cannot rebuild or run to verify; flagged for separate, deliberate work instead. Co-Authored-By: Claude Sonnet 5 --- .../JournalEntryReadPlatformServiceImpl.java | 27 ++++++----- .../JournalEntryReferralServiceImpl.java | 16 +++++-- .../ChequeBookIssuanceChargeService.java | 28 ++++++++--- .../service/ClearingJournalService.java | 18 ++++++-- .../ClerkDisbursementRequestApiResource.java | 46 +++++++++++++------ ...DIssueRequestWritePlatformServiceImpl.java | 35 ++++++++++---- 6 files changed, 123 insertions(+), 47 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java index 8115f66ea..7169ba0ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java @@ -105,17 +105,22 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ") .append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append(" eao.external_id as externalAssetOwner, ") - // entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and - // current-account entries -- current products deliberately share SAVING's GL type - // space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two - // apart on its own, and m_savings_account/m_current_account each have their own id - // sequence: an entity_id can be a real row in both, making a plain - // COALESCE(ca.account_no, sa.account_no) pick the current account's number even for - // an entry that is genuinely a savings one. savings_transaction_id is the one - // unambiguous signal available here -- only a real savings transaction sets it -- so - // it decides first; only when it's absent (a current-account entry, or a savings - // entry with no linked transaction row) does the current-account guess apply. + // entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in + // m_current_account -- newer write paths tag current-account entries with it + // instead of reusing SAVING's type, specifically so this case needs no guessing. + // + // entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings + // and current-account entries by older write paths not yet updated to the above -- + // current products used to deliberately share SAVING's GL type space (see + // PortfolioProductType.CURRENT's own comment) -- and m_savings_account/ + // m_current_account each have their own id sequence, so an entity_id can be a real + // row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current + // account's number even for an entry that is genuinely a savings one. + // savings_transaction_id is the one unambiguous signal available for those older + // entries -- only a real savings transaction sets it -- so it decides first; only + // when it's absent does the current-account guess apply. .append(" CASE ") + .append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ") .append(" ELSE NULL ") @@ -141,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ") .append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ") .append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ") - .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 "); + .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) "); if (associationParametersData.isTransactionDetailsRequired()) { sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ") .append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java index db4ec204d..0e1e65ff3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java @@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ * The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL * posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable. */ + // m_savings_account and m_current_account each keep their own identity sequence, so the same + // entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to + // be written for both savings and current-account entries alike, so esa/eca could both match the + // same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever + // client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of + // misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT) + // is what newer write paths tag current-account entries with instead, unambiguously; for older + // entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine + // savings transaction sets it), checked before the ambiguous current/savings guess. private static final String RESOLVE_SQL = """ SELECT je.amount AS amount, je.currency_code AS currencyCode, @@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ je.loan_transaction_id AS loanTxnId, je.entity_type_enum AS entityType, COALESCE( - esa.client_id, - eca.client_id, + CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END, + CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END, + CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END, eloan.client_id, CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END, sa.client_id, @@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ ) AS clientId FROM acc_gl_journal_entry je LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2 - LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2 + LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6) LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1 LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index 66d0c6950..1954371b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; @@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService { } postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId, - accountId); + accountId, accountType); raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId); log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, chequeBookId); @@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService { /** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */ private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, - final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) { + final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId, + final ChequeAccountType accountType) { final JsonArray debitArr = new JsonArray(); final JsonObject debit = new JsonObject(); debit.addProperty("glAccountId", debitGlId); @@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService { // link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do. final String glTxnId = result.getTransactionId(); if (glTxnId != null) { - linkDebitEntryToAccount(glTxnId, accountId); + linkDebitEntryToAccount(glTxnId, accountId, accountType); } } - /** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */ - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an + * Account No. + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can + * be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of + * accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings + * account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually + * means "this id is in m_current_account" -- use it whenever accountType says so. + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = accountType == ChequeAccountType.CURRENT + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java index 9180b5bf6..535ce45c9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java @@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; @@ -118,7 +119,7 @@ public class ClearingJournalService { } final String glTxnId = result.getTransactionId(); if (glTxnId != null && linkAccountId != null) { - linkDebitEntryToAccount(glTxnId, linkAccountId); + linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType); } raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType); return glTxnId; @@ -191,7 +192,13 @@ public class ClearingJournalService { return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); } - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two + * account tables number independently, so entity_type_enum must say which one accountId names, not + * assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means + * "this id is in m_current_account". + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -200,8 +207,11 @@ public class ClearingJournalService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = "CURRENT".equalsIgnoreCase(accountType) + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index b94837355..56f55e3ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.voucher.service.VoucherService; @@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource { ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); btTotalDr = btTotalDr.add(entryAmt); if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); @@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); } @@ -681,7 +682,7 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(directCaTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true)); undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId)); } else { // Real CBS book transfer: direct credit to savings account. @@ -696,7 +697,7 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(directTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false)); undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId)); } } else { @@ -715,7 +716,7 @@ public class ClerkDisbursementRequestApiResource { : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(debitTxnId)); withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); - collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount)); if (isCurrentAccount) { undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId)); } else { @@ -979,7 +980,7 @@ public class ClerkDisbursementRequestApiResource { : directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); } else { @@ -1005,7 +1006,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); } else { @@ -1568,20 +1569,27 @@ public class ClerkDisbursementRequestApiResource { final BigDecimal amount; final String accountRef; // savings account number shown in GL comments; null for source/target GL final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries - final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries + final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries + final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId) { - this(glAccountId, amount, accountRef, txnId, null); + this(glAccountId, amount, accountRef, txnId, null, false); } GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, final Long savingsAccountId) { + this(glAccountId, amount, accountRef, txnId, savingsAccountId, false); + } + + GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, + final Long savingsAccountId, final boolean isCurrentAccount) { this.glAccountId = glAccountId; this.amount = amount; this.accountRef = accountRef; this.txnId = txnId; this.savingsAccountId = savingsAccountId; + this.isCurrentAccount = isCurrentAccount; } } @@ -1603,8 +1611,16 @@ public class ClerkDisbursementRequestApiResource { /** * After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1) - * row to its savings account so the GL view shows ACCOUNT NO instead of "—". + * row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—". * Rows are matched to entries by insertion order (Fineract inserts in JSON array order). + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2 + * (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view + * would resolve a current account's entry to a same-numbered savings account or vice versa. + * entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's + * entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one + * this entry's id actually is, decided once already, where the account was first resolved. */ private void linkGlEntriesToSavingsAccounts(final String glTxnId, final List entries, final int typeEnum) { @@ -1618,11 +1634,15 @@ public class ClerkDisbursementRequestApiResource { Long.class, glTxnId, typeEnum, entries.size()); java.util.Collections.reverse(rowIds); for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) { - final Long savingsAccId = entries.get(i).savingsAccountId; + final GlEntry entry = entries.get(i); + final Long savingsAccId = entry.savingsAccountId; if (savingsAccId != null) { + final int entityType = entry.isCurrentAccount + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", - savingsAccId, rowIds.get(i)); + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", + entityType, savingsAccId, rowIds.get(i)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index d263de1bc..54471b2db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine; @@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null; final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null; @@ -627,6 +629,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); @@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD issuance - DD No " + ddNumber; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, - chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft( leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, @@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI Long resultingCurrentTransactionId = null; final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; // Book debit, no journal entry of its own -- see approveIssueRequest(). @@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException( @@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges, - chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); for (final Map request : requests) { this.sponsorDDWritePlatformService.issueDemandDraft( @@ -1757,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI * liability, so they get their own credit leg rather than being folded into the liability). Resolves * the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request / * m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's - * savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 / - * entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching - * how Fineract's own auto-generated entries display. + * savings/current account for an account-debit DD), the Dr-side row is tagged + * entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so + * GL reports show the account number instead of a blank "—", matching how Fineract's own + * auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account + * each with their own sequence) can't resolve a same-numbered stranger's account instead. */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId, - txnDate, comments, linkedAccountId); + txnDate, comments, linkedAccountId, false); } /** @@ -1811,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, - final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { + final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId, + final boolean linkedAccountIsCurrent) { final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0; @@ -1872,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever // being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly. if (linkedAccountId != null) { + // m_current_account and m_savings_account each keep their own identity sequence, so the same + // numeric id can be a real row in both -- entity_type_enum must say which one linkedAccountId + // names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit + // branch resolves either a savingsAccountId or a currentAccountId into this same parameter). + final int entityType = linkedAccountIsCurrent + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = (" + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = (" + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + " ORDER BY id DESC LIMIT 1)", - linkedAccountId, transactionId); + entityType, linkedAccountId, transactionId); } final List> rows = this.jdbcTemplate.queryForList( From 2155c94161e0209792d54a2904ead7256775c421 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 11:41:18 +0530 Subject: [PATCH 16/39] fix(fd): Reinvest Principal Only renews in place, same account, instead of opening a new one Reinvest Principal Only was still on the legacy close-and-reopen path (open a new FD with just the principal, close the old one) while Reinvest Principal + Interest and Partial Reinvest had already moved to renewInPlace -- the same customer choosing a different split ended up with two different outcomes: same account number, or a brand new one. Unified all three onto renewInPlace: Principal Only is now exactly Partial Reinvest with the split fixed at the closing principal, so it renews on the same account with only the leftover interest actually leaving (to the chosen/linked Savings account), in both handleFDAccountPayment (Payment dialog / FD Dashboard Renew) and handleFDAccountMaturityClosure (the nightly job's path for an account with an already-stored instruction). Also fixes a related bug found while testing this: recordInPlaceRenewal re-archived the closing term's full rate chart on every renewal of the same term, even when it had already been archived by an earlier renewal -- archiveChartSlabs now only runs the first time a term's closing row is created. Co-Authored-By: Claude Sonnet 5 --- .../DepositAccountDomainServiceJpa.java | 193 ++++++------------ ...WritePlatformServiceJpaRepositoryImpl.java | 23 +-- 2 files changed, 72 insertions(+), 144 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index a884d9f19..acdd50fdc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); account.setClosedOnDate(closedDate); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest()) { + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) { // Renews on the SAME account, so the customer keeps one account number however many times - // the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new - // term's principal, so there is no withdrawal, no deposit and no journal entry for the - // rollover itself -- which also removes the transfer-suspense round trip the old - // close-and-reopen flow needed. + // the deposit rolls over -- Reinvest Principal Only renews just the closing principal here + // (below), with only the leftover interest actually leaving, rather than the legacy + // close-and-reopen this branch used to use, which left the customer with a second account + // number for something that renews here just as cleanly as Reinvest Principal + Interest + // does. Full reinvest itself still moves nothing: the whole matured balance simply becomes + // the new term's principal, so there is no withdrawal, no deposit and no journal entry for + // the rollover itself. final BigDecimal closingPrincipal = account.getDepositAmount(); final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null : maturedBalance.subtract(closingPrincipal); + final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance; + // Whatever doesn't renew is interest the customer earned and has to actually be paid out + // somewhere -- validated before anything below runs, so a missing target fails cleanly with + // nothing done rather than leaving a half-completed reinvestment. + final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount); + if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { + throw new GeneralPlatformDomainRuleException( + "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", + "Reinvest Principal Only requires a Savings account to receive the leftover interest."); + } // The active CBS base interest-rate version for this FD type: the same rate card the Base // Interest Rates screen shows and the account-opening wizard prices from. Used for both the @@ -337,66 +350,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer closingTenure = account.depositPeriod(); final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); - account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); this.savingsAccountRepository.save(account); - recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate, + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate, closingTenure, closingTenureFrequencyEnum); changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - } else if (onClosureType.isReinvest()) { - // "Re-invest principal only" still closes and reopens, because only the principal carries - // forward and the interest has to leave the deposit -- which needs a payout destination that - // an in-place renewal has nowhere to send. Left on the original flow rather than guessing. - final BigDecimal maturedBalance = account.getAccountBalance(); - final BigDecimal reInvestAmount = account.getDepositAmount(); - // Whatever is left after the principal moves into the new deposit is interest the customer - // earned and has to actually be paid out somewhere -- it must not just sit on this account, - // which is about to be closed. Validated before anything below runs, so a missing target - // fails cleanly with nothing done rather than leaving a half-completed reinvestment. - final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount); - if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { - throw new GeneralPlatformDomainRuleException( - "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", - "Reinvest Principal Only requires a Savings account to receive the leftover interest."); - } - ExternalId externalId = this.externalIdFactory.create(); - - FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); - autoGenerateAccountNumber(reinvestedDeposit); - // Renewal moves the balance internally into the new deposit -- no cash leaves the branch -- - // so this leg is posted as an account transfer (against the transfer-suspense GL) rather - // than as a counter withdrawal, which would wrongly credit the cash/vault account. - final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction, isInternalTransferLeg); - savingsTransactionId = withdrawal.getId(); - // handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as - // already accounted for -- exactly as the transfer-to-savings branch below does -- or the - // postJournalEntries() call at the end of this method posts it a second time. - updateAlreadyPostedTransactions(existingTransactionIds, account); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - // Reached from the maturity job processing the account's standing maturity instruction. - recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, - FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); - - // Pay out whatever didn't move into the new deposit, then actually close this account -- - // previously this branch left the leftover interest stranded on an account that was never - // closed, silently, because nothing past this point ever referenced toSavingsId at all. + // Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is + // zero (renewedAmount already equals maturedBalance), so this stays a no-op for it. if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); @@ -409,8 +376,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); } - account.updateClosedStatus(); - account.updateOnAccountClosureStatus(onClosureType); } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, @@ -751,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance, closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure, closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById)); + // Only on this first-ever close of this term -- otherwise a term that renews more than once + // (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the + // same chart against the same closingRow every time, piling up duplicate slab rows for a + // chart that only ever needs archiving once. + archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); } closingRow.recordOutcome(closingInterestEarned, maturedBalance); closingRow.markMatured(); this.fixedDepositRenewalHistoryRepository.save(closingRow); - archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); - // The new term's own interest/maturity stay unset until it closes in turn. this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(), account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount, @@ -884,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi * A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close * (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support * -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest - * transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through - * {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what - * the existing manual Close command still uses today. + * transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and + * Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just + * Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with + * only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest} + * this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's + * own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an + * already-stored instruction). *

* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} -- * neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as @@ -953,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) { - // These two branches keep the account open, so they never reach close()/prematureClosure() and - // would otherwise accept any date at all -- including one behind already-posted interest, which - // the renewal would then turn into a pivot that reverses and re-posts it. + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { + // These three branches keep the account open, so they never reach close()/prematureClosure() + // and would otherwise accept any date at all -- including one behind already-posted interest, + // which the renewal would then turn into a pivot that reverses and re-posts it. account.validateRenewalDate(closedDate); final BigDecimal closingPrincipal = account.getDepositAmount(); @@ -972,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid", "The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount); } + } else if (onClosureType.isReinvestPrincipal()) { + // Exactly the closing term's own principal -- the fixed point on the same split Partial + // Reinvest lets staff pick freely. Renews in place same as the other two, on just this + // account, with only the interest actually leaving (below) -- no second account, unlike the + // legacy close-and-reopen this branch used to do. + renewedAmount = closingPrincipal; } else { renewedAmount = maturedBalance; } @@ -1004,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - if (onClosureType.isPartialReinvest()) { + if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { // The kept portion never left the account -- renewInPlace() just relabels it as the new - // term's principal -- so only the remainder is an actual money movement, posted the same - // way the Transfer to Savings branch below posts its full-balance transfer. + // term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is + // an actual money movement, posted the same way the Transfer to Savings branch below posts + // its full-balance transfer. final BigDecimal remainder = maturedBalance.subtract(renewedAmount); - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS, - PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null, - AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, - isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - } else if (onClosureType.isReinvestPrincipal()) { - // Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch -- - // the interest has to leave the deposit, which needs a payout destination an in-place renewal - // has nowhere to send. - final BigDecimal maturedBalance = account.getAccountBalance(); - final BigDecimal reInvestAmount = account.getDepositAmount(); - final ExternalId externalId = this.externalIdFactory.create(); - - final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); - autoGenerateAccountNumber(reinvestedDeposit); - final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction, isInternalTransferLeg); - savingsTransactionId = withdrawal.getId(); - updateAlreadyPostedTransactions(existingTransactionIds, account); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, - FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); - - // Only the principal moved into the new deposit -- the interest earned on this term is still - // sitting in this account's balance. Pay it out to the chosen savings account now, before - // closing, rather than stranding it here with nowhere left to go. - final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount); - if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) { - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder, - PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, - null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, - toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - if (alreadyMatured) { - account.close(user, command, changes); - } else { - account.prematureClosure(user, command, changes); + if (remainder.compareTo(BigDecimal.ZERO) > 0) { + final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); + final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, + null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, + toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } } } else if (onClosureType.isTransferToSavings()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 5f4b58c09..42e258e8e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes); - // Reinvest Principal Only opens a second account and the domain method withdrew the principal out - // of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually - // land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path. + // Reinvest Principal Only now renews in place on this same account (see + // DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually + // set any more, but this guard is left as a harmless no-op rather than removed, in case a future + // closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler @@ -1566,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // would treat it as new and post it a second time without this. markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); + // Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, + // in case a future closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler .assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); - // activateWithBalance() always returns zero here -- it exists to stop a manually - // opened FD from auto-crediting itself at activation (funding is supposed to be a - // separate, clerk-posted deposit). A reinvestment isn't that: the balance was just - // withdrawn from the matured account and needs to land in the new one as part of - // this same automated closure, so it has to come from what reInvest() actually - // carried over onto the new account, not from that unrelated activation guard. Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount()); Money activationChargeAmount = getActivationCharge(reinvestAccount); if (activationChargeAmount.isGreaterThanZero()) { @@ -1663,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); - // REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId); - // REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that - // actually carries the new term's maturity date/amount either way. + // Both reinvest types now renew this same account in place (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, in case + // a future closure type reintroduces a genuine close-and-reopen. FixedDepositAccount renewedAccount = fdAccount; if (changes.get("reinvestedDepositId") != null) { renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"), From 6ee3bbe67c8117a75cbf583571e27d3ecc894038 Mon Sep 17 00:00:00 2001 From: anshul Date: Mon, 28 Sep 2026 12:55:21 +0530 Subject: [PATCH 17/39] feat: add command handlers, permissions, and API resources for surveys and loan COB catch-up --- .../service/CommandWrapperBuilder.java | 32 ++++++ .../cob/api/LoanCOBCatchUpApiResource.java | 3 + .../fineract/spm/api/SpmApiResource.java | 48 ++++---- .../handler/ActivateSurveyCommandHandler.java | 53 +++++++++ .../handler/CreateSurveyCommandHandler.java | 71 ++++++++++++ .../DeactivateSurveyCommandHandler.java | 53 +++++++++ .../handler/UpdateSurveyCommandHandler.java | 72 ++++++++++++ .../PermissionReadPlatformServiceImpl.java | 18 ++- .../tenant/final-changelog-tenant.xml | 3 + .../0584_add_manage_survey_permissions.xml | 68 +++++++++++ .../0585_add_loan_cob_catchup_permission.xml | 44 +++++++ ..._add_system_module_checker_permissions.xml | 107 ++++++++++++++++++ 12 files changed, 545 insertions(+), 27 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/spm/handler/ActivateSurveyCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/spm/handler/CreateSurveyCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/spm/handler/DeactivateSurveyCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/spm/handler/UpdateSurveyCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_add_manage_survey_permissions.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_add_loan_cob_catchup_permission.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_add_system_module_checker_permissions.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 509a6f71e..0f2140efd 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -4809,4 +4809,36 @@ public class CommandWrapperBuilder { this.href = "/loans/" + loanId + "/originators/" + originatorId; return this; } + + public CommandWrapperBuilder createSurvey() { + this.actionName = "CREATE"; + this.entityName = "SURVEY"; + this.entityId = null; + this.href = "/surveys"; + return this; + } + + public CommandWrapperBuilder updateSurvey(final Long surveyId) { + this.actionName = "UPDATE"; + this.entityName = "SURVEY"; + this.entityId = surveyId; + this.href = "/surveys/" + surveyId; + return this; + } + + public CommandWrapperBuilder activateSurvey(final Long surveyId) { + this.actionName = "ACTIVATE"; + this.entityName = "SURVEY"; + this.entityId = surveyId; + this.href = "/surveys/" + surveyId; + return this; + } + + public CommandWrapperBuilder deactivateSurvey(final Long surveyId) { + this.actionName = "DEACTIVATE"; + this.entityName = "SURVEY"; + this.entityId = surveyId; + this.href = "/surveys/" + surveyId; + return this; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java index f29866125..448ed615d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java @@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO; import org.apache.fineract.cob.service.LoanCOBCatchUpService; import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException; import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.springframework.stereotype.Component; @Path("/v1/loans") @@ -44,6 +45,7 @@ import org.springframework.stereotype.Component; public class LoanCOBCatchUpApiResource { private final Optional loanCOBCatchUpServiceOp; + private final PlatformSecurityContext context; @GET @Path("oldest-cob-closed") @@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource { @ApiResponse(responseCode = "202", description = "Catch Up has been started") @ApiResponse(responseCode = "400", description = "Catch Up is already running") public Response executeLoanCOBCatchUp() { + this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP"); return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> { if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) { return Response.status(Response.Status.BAD_REQUEST).build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java index c8c6ff120..cce2ef2d2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java @@ -18,7 +18,6 @@ */ package org.apache.fineract.spm.api; -import com.google.gson.Gson; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -37,10 +36,14 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.spm.data.SurveyData; import org.apache.fineract.spm.domain.Survey; @@ -57,6 +60,8 @@ public class SpmApiResource { private final PlatformSecurityContext securityContext; private final SpmService spmService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -103,12 +108,10 @@ public class SpmApiResource { @Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n" + "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) - public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) { - this.securityContext.authenticatedUser(); - final Survey survey = SurveyMapper.map(surveyData, new Survey()); - this.spmService.createSurvey(survey); - return getResponse(survey.getId()); - + public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + return this.toApiJsonSerializer.serialize(result); } @PUT @@ -116,12 +119,10 @@ public class SpmApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Transactional - public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) { - this.securityContext.authenticatedUser(); - final Survey surveyToUpdate = this.spmService.findById(id); - final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate); - this.spmService.updateSurvey(survey); - return getResponse(survey.getId()); + public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + return this.toApiJsonSerializer.serialize(result); } @POST @@ -131,22 +132,17 @@ public class SpmApiResource { @Transactional @Operation(summary = "Deactivate Survey", description = "") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) - public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) { - this.securityContext.authenticatedUser(); + public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) { + final CommandWrapperBuilder builder = new CommandWrapperBuilder(); + final CommandWrapper commandRequest; if (command != null && command.equalsIgnoreCase("activate")) { - this.spmService.activateSurvey(id); + commandRequest = builder.activateSurvey(id).build(); } else if (command != null && command.equalsIgnoreCase("deactivate")) { - this.spmService.deactivateSurvey(id); + commandRequest = builder.deactivateSurvey(id).build(); } else { throw new UnrecognizedQueryParamException("command", command); } - - } - - private String getResponse(Long id) { - Gson gson = new Gson(); - HashMap response = new HashMap<>(); - response.put("resourceId", id); - return gson.toJson(response); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + return this.toApiJsonSerializer.serialize(result); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/ActivateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/ActivateSurveyCommandHandler.java new file mode 100644 index 000000000..bae02b9da --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/ActivateSurveyCommandHandler.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.spm.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.spm.service.SpmService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "ACTIVATE") +public class ActivateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + + @Autowired + public ActivateSurveyCommandHandler(final SpmService spmService) { + this.spmService = spmService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + this.spmService.activateSurvey(command.entityId()); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(command.entityId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/CreateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/CreateSurveyCommandHandler.java new file mode 100644 index 000000000..06c2e162f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/CreateSurveyCommandHandler.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.spm.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.spm.data.SurveyData; +import org.apache.fineract.spm.domain.Survey; +import org.apache.fineract.spm.service.SpmService; +import org.apache.fineract.spm.util.SurveyMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "CREATE") +public class CreateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + private final ObjectMapper objectMapper; + + @Autowired + public CreateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) { + this.spmService = spmService; + this.objectMapper = objectMapper; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SurveyData surveyData = readSurveyData(command); + final Survey survey = SurveyMapper.map(surveyData, new Survey()); + this.spmService.createSurvey(survey); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(survey.getId()) // + .build(); + } + + private SurveyData readSurveyData(final JsonCommand command) { + try { + return this.objectMapper.readValue(command.json(), SurveyData.class); + } catch (final JsonProcessingException e) { + throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage()); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/DeactivateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/DeactivateSurveyCommandHandler.java new file mode 100644 index 000000000..3d9e4bfdc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/DeactivateSurveyCommandHandler.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.spm.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.spm.service.SpmService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "DEACTIVATE") +public class DeactivateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + + @Autowired + public DeactivateSurveyCommandHandler(final SpmService spmService) { + this.spmService = spmService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + this.spmService.deactivateSurvey(command.entityId()); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(command.entityId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/UpdateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/UpdateSurveyCommandHandler.java new file mode 100644 index 000000000..9452e818f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/UpdateSurveyCommandHandler.java @@ -0,0 +1,72 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.spm.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.spm.data.SurveyData; +import org.apache.fineract.spm.domain.Survey; +import org.apache.fineract.spm.service.SpmService; +import org.apache.fineract.spm.util.SurveyMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "UPDATE") +public class UpdateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + private final ObjectMapper objectMapper; + + @Autowired + public UpdateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) { + this.spmService = spmService; + this.objectMapper = objectMapper; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SurveyData surveyData = readSurveyData(command); + final Survey surveyToUpdate = this.spmService.findById(command.entityId()); + final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate); + this.spmService.updateSurvey(survey); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(survey.getId()) // + .build(); + } + + private SurveyData readSurveyData(final JsonCommand command) { + try { + return this.objectMapper.readValue(command.json(), SurveyData.class); + } catch (final JsonProcessingException e) { + throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage()); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java index 6b5345abb..17475b1fd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java @@ -114,17 +114,33 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform + "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT'," + "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'"; + /* + * Unlike DEAD_MAKER_CHECKER_CODES above, these codes ARE read by the live maker-checker pipeline + * (CommandSourceService.isMakerCheckerEnabledForTask()) - they are excluded from this screen for a + * different reason: UPDATE_PERMISSION governs this very "Configure Maker Checker Tasks" screen + * (toggling which tasks require maker-checker), and UPDATE_CONFIGURATION governs the global + * maker-checker on/off switch and other System -> Configurations settings. If either were flipped + * to can_maker_checker=true and the approving checker.user lacked CHECKER_SUPER_USER, an admin + * could lock themselves out of ever approving further changes to maker-checker configuration + * itself, with no ordinary way to reverse it. Excluding them here only hides them from this + * screen; it does not touch their can_maker_checker value or their normal enforcement. + */ + private static final String SELF_REFERENTIAL_MAKER_CHECKER_CODES = "'UPDATE_PERMISSION','UPDATE_CONFIGURATION'"; + public String makerCheckerablePermissionSchema() { /* * get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping, * the READ permissions and the CHECKER permissions), and exclude permission codes whose approval - * workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above). + * workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above), as well as + * codes that control maker-checker configuration itself (see SELF_REFERENTIAL_MAKER_CHECKER_CODES + * above) to avoid a self-lockout. */ return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected" + " from m_permission p " + " where " + sqlGenerator.escape("grouping") + " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'" + " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")" + + " and code not in (" + SELF_REFERENTIAL_MAKER_CHECKER_CODES + ")" + " order by p.grouping, coalesce(entity_name, ''), p.code"; } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 03cb272b1..e43230fdb 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -149,4 +149,7 @@ dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file name and this changeset's id were updated to match. --> + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_add_manage_survey_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_add_manage_survey_permissions.xml new file mode 100644 index 000000000..68e5330ab --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_add_manage_survey_permissions.xml @@ -0,0 +1,68 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_SURVEY' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + JOIN m_permission p ON p.code IN ('CREATE_SURVEY', 'UPDATE_SURVEY', 'ACTIVATE_SURVEY', 'DEACTIVATE_SURVEY') + WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = r.id AND rp2.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_add_loan_cob_catchup_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_add_loan_cob_catchup_permission.xml new file mode 100644 index 000000000..9cbdd9553 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_add_loan_cob_catchup_permission.xml @@ -0,0 +1,44 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'EXECUTE_LOANCOBCATCHUP' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + JOIN m_permission p ON p.code = 'EXECUTE_LOANCOBCATCHUP' + WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = r.id AND rp2.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_add_system_module_checker_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_add_system_module_checker_permissions.xml new file mode 100644 index 000000000..150bffec5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_add_system_module_checker_permissions.xml @@ -0,0 +1,107 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_ACCOUNTNUMBERFORMAT_CHECKER' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, checker.id + FROM m_role_permission rp + JOIN m_permission base ON base.id = rp.permission_id + JOIN m_permission checker ON checker.code = base.code || '_CHECKER' + WHERE base.code IN ('CREATE_ACCOUNTNUMBERFORMAT', 'UPDATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT', + 'CREATE_ENTITYMAPPING', 'UPDATE_ENTITYMAPPING', 'DELETE_ENTITYMAPPING', + 'CREATE_USERAGREEMENT', 'UPDATE_BATCH_BUSINESS_STEP') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id + ) + + + + From b12268e740264f69dd86f13323e6ae035ce579e6 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 28 Sep 2026 13:35:32 +0530 Subject: [PATCH 18/39] feat: add RBI Form A and Form 9 SLR filing APIs, jobs, and migrations --- ...avingsProductToGLAccountMappingHelper.java | 7 + .../infrastructure/jobs/service/JobName.java | 1 + ...ccountMappingWritePlatformServiceImpl.java | 12 + .../api/BranchDayRegisterApiResource.java | 4 +- .../api/CashierDepositRequestApiResource.java | 26 +- .../api/CashReconciliationApiResource.java | 4 +- .../cashreport/api/CashReportApiResource.java | 31 +- .../ClerkWithdrawalRequestApiResource.java | 5 +- .../service/CountedCashLedger.java | 49 +++ .../service/DenominationBreakdownWriter.java | 8 +- .../api/RbiFormAFilingApiResource.java | 305 ++++++++++++++++++ .../api/RbiReserveConfigApiResource.java | 9 +- .../api/RbiReserveReturnApiResource.java | 84 +++-- .../SaveRbiFormAReturnJobConfig.java | 60 ++++ .../SaveRbiFormAReturnTasklet.java | 42 +++ .../service/RbiFormAAutoSaveService.java | 153 +++++++++ .../tenant/final-changelog-tenant.xml | 3 + .../tenant/parts/0582_form_a_saved_report.xml | 87 +++++ .../parts/0583_save_rbi_form_a_return_job.xml | 72 +++++ .../tenant/parts/0584_form9_slr_assets.xml | 118 +++++++ 20 files changed, 1020 insertions(+), 60 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java index 2fa7358c0..cc489048f 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java @@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes); + // Interest Payable is editable on cash too, and optional. Interest posting routes + // through it on either basis, so a product can name one to carry interest declared + // but not yet credited; a product that names none still posts straight to Savings + // Control. Created-or-merged because a product configured before this row existed + // has no mapping to merge into. + createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), + savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes); break; case ACCRUAL_PERIODIC: // asset diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index ee5b62605..ba41bb64c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -73,6 +73,7 @@ public enum JobName { CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), // AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), // CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), // + SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), // ; // private final String name; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java index 35af580f8..ef33e9074 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java @@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc break; case CASH_BASED: saveSavingsBaseAccountMapping(savingProductId, accountType, command, element); + // Interest Payable is saved on cash too, and optionally: interest posting routes + // through it on either basis (see AccountingProcessorHelper + // .createInterestPostingJournalEntriesForSavings), so that interest declared but not + // yet credited to the member reads as the bank's own liability rather than appearing + // only at the moment it is paid. Mapped only when the screen supplies it - a product + // naming none still posts straight to Savings Control, as it always did. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), + element)) { + this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element, + SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId, + AccrualAccountsForSavings.INTEREST_PAYABLE.getValue()); + } break; case ACCRUAL_PERIODIC: diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index cbfc2ee8e..31ac82030 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -200,7 +200,7 @@ public class BranchDayRegisterApiResource { final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' - AND """ + CountedCashLedger.REAL_TXN_ID + """ + AND """ + CountedCashLedger.COUNTED_ROW + """ AND (std.officer_id IS NULL OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id WHERE au.id = std.officer_id) @@ -211,7 +211,7 @@ public class BranchDayRegisterApiResource { final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' - AND """ + CountedCashLedger.REAL_TXN_ID + """ + AND """ + CountedCashLedger.COUNTED_ROW + """ AND (std.officer_id IS NULL OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id WHERE au.id = std.officer_id) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 592a66d32..5b71779ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -192,7 +192,7 @@ public class CashierDepositRequestApiResource { // reject() will delete these records so the vault position is reversed. if (denominationData != null && !denominationData.isBlank()) { savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId, - currentUser.getId(), currentUser.getUsername(), paymentTypeName); + currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate); } return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo)); @@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource { updatedLog, Timestamp.from(Instant.now()), id ); + // Rejection deleted this request's notes (the cash went back to the customer). Resubmitting + // means the cashier took the cash again, so the notes are recorded again -- without this an + // approved resubmission credited the account while the drawer never received its notes. + // Cleared first so a resubmit can never leave the old breakdown alongside the new one. + jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET); + if (denominationData != null && !denominationData.isBlank()) { + savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")), + (String) record.get("account_no"), toLong(record.get("client_id")), + currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate); + } + // Keep the central voucher register in sync with the edited amount voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount); @@ -1004,9 +1015,14 @@ public class CashierDepositRequestApiResource { */ // Called at submit time — uses request id as temporary txn_id. // approve() updates txn_id to real Fineract id; reject() deletes these records. + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + private void savePendingDenomination(final String denominationData, final Long requestId, final Long savingsAccountId, final String accountNo, - final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) { + final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName, + final String txnDate) { final JsonObject denomMap; try { denomMap = gson.fromJson(denominationData, JsonObject.class); @@ -1037,10 +1053,14 @@ public class CashierDepositRequestApiResource { INSERT INTO m_savings_txn_denomination (txn_id, savings_account_id, account_no, client_id, denomination_id, denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?) """, requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId, value, count, BigDecimal.valueOf((long) value * count), + // Dated on the request's own date, which is what the branch day's book figure reads. + // CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the + // drawer on another and leave both days' cash wrong. + txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null, cashierId, cashierUsername, paymentTypeName ); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index 8f3cb2437..19a757709 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -90,7 +90,7 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'DEPOSIT' " + "AND std.txn_date <= CAST(? AS DATE) " - + "AND " + CountedCashLedger.REAL_TXN_ID + + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " @@ -120,7 +120,7 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'WITHDRAWAL' " + "AND std.txn_date <= CAST(? AS DATE) " - + "AND " + CountedCashLedger.REAL_TXN_ID + + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index edde51cf2..761ca117f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -60,34 +60,11 @@ public class CashReportApiResource { * * *

- * Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared - * with the day register, the reconciliation screen and the withdrawal stock check, so a drawer - * reads the same on all four. + * The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW}, + * shared with the day register, the reconciliation screen and the withdrawal stock check, so a + * drawer reads the same on all four. */ - private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS (" - + " SELECT 1 FROM m_savings_account_transaction rt " - + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) " - + "AND " + CountedCashLedger.REAL_TXN_ID - // A breakdown has to add up to the transaction it belongs to. Where it does not, the - // notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes - // recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven - // such transactions in this database were carrying ₹1.68 lakh that never existed. - // - // The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation, - // so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the - // nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed - // breakdown, and treating it as one silently dropped an entire genuine withdrawal (and - // everything counted after it) out of the cashier's position. - // - // Compared per transaction inside a correlated subquery rather than as a join, so a - // breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a - // manual GL entry keyed by its decoded reference -- is untouched by this test and left to - // CountedCashLedger's rule above. - + "AND NOT EXISTS (" - + " SELECT 1 FROM m_savings_account_transaction mt " - + " WHERE mt.id = std.txn_id" - + " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)" - + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) "; + private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW; /** * Day book — every voucher raised on one date, in one list, newest first. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 57f9cb070..d579ec92e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -722,10 +722,13 @@ public class ClerkWithdrawalRequestApiResource { INSERT INTO m_savings_txn_denomination (txn_id, savings_account_id, account_no, client_id, denomination_id, denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'WITHDRAWAL', ?, ?, ?) """, txnId, savingsAccountId, accountNo, clientId, denomId, value, count, BigDecimal.valueOf((long) value * count), + // The request's own date, which the branch day's paid-out figure reads -- not the + // database clock, which put the notes on a different day from the withdrawal. + record.get("txn_date"), cashier.getId(), cashier.getUsername(), paymentTypeName ); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java index 3fa7bcd11..d07b43810 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java @@ -87,4 +87,53 @@ public final class CountedCashLedger { + " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN" + " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS + " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") "; + + /** + * {@link #REAL_TXN_ID} plus the two tests a real id can still fail, as one predicate over the alias + * {@code std}. Every drawer figure reads this, not {@code REAL_TXN_ID} alone. + * + *

+ * Only the cashier position report used to apply the two extra tests, so it and the reconciliation + * screen disagreed: a reversed ₹200 deposit left the settlement screen expecting three ₹100 notes + * while the settlement check, reading the position, allowed one — and refused the hand-over. + * + *

    + *
  • a breakdown whose savings transaction is reversed -- the money went back, so the notes did too. + * The table has no reversal marker of its own, so it is read off the transaction; + *
  • a breakdown that does not add up to its transaction -- ₹1,60,000 of notes against a ₹918 + * deposit is mis-keyed, not cash. Sub-rupee tolerance, because a paisa-exact amount is handed + * over as the nearest whole rupee. + *
+ * + *

+ * The transaction is found by id and account number together. Savings and current + * transactions keep separate id sequences, and a current-account deposit writes its own + * transaction id into {@code txn_id}. Matched on id alone, a ₹210 current-account deposit was + * judged against an unrelated savings transaction that happened to share its id, failed the + * amount test, and vanished from the cashier's drawer. A row with no account number (older + * writers) is still matched on id alone, as before. + * + *

+ * All tests are correlated {@code NOT EXISTS} rather than joins, so a breakdown with no account + * transaction behind it (a Demand Draft, a manual GL entry) is untouched by them. + */ + public static final String COUNTED_ROW = " (NOT EXISTS (" + + " SELECT 1 FROM m_savings_account_transaction rt" + + " JOIN m_savings_account rsa ON rsa.id = rt.savings_account_id" + + " WHERE rt.id = std.txn_id" + + " AND (std.account_no IS NULL OR rsa.account_no = std.account_no)" + + " AND (rt.is_reversed = true OR " + breakdownDiffersFrom("rt.amount") + "))" + + " AND NOT EXISTS (" + + " SELECT 1 FROM m_current_account_transaction rct" + + " JOIN m_current_account rca ON rca.id = rct.current_account_id" + + " WHERE rct.id = std.txn_id AND rca.account_no = std.account_no" + + " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))" + + " AND" + REAL_TXN_ID + ") "; + + /** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */ + private static String breakdownDiffersFrom(final String amountColumn) { + return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)" + + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id" + + " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1"; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index 3d22b4fdf..02a6498af 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -286,7 +286,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -310,7 +310,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -407,7 +407,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -431,7 +431,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java new file mode 100644 index 000000000..fdb524c9a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java @@ -0,0 +1,305 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Saved Form A / Form 9 returns: keeping a generated return so it can be read again later exactly as + * it was generated. + * + * A saved return is served from its stored snapshot, never recomputed. Recomputing would quietly + * produce a different statement from the one that was reviewed or filed - a backdated journal entry, + * a changed GL mapping or a corrected rate all move figures that were already reported - and a + * report whose figures change after it was filed is worse than no report at all. + * + * Deliberately not maker-checker: saving a snapshot changes no balance and posts no entry. The + * filing lifecycle (draft, checked, approved, submitted) is what carries the approval, and that is + * recorded on the row's status rather than in a separate command queue. + */ +@Path("/v1/rbiformafiling") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiFormAFilingApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIFORMAFILING"; + + /** + * The filing lifecycle RBI's own timetable implies: a return is drafted, checked and approved + * inside the bank, sent provisionally within a week of the fortnight ending and finally within + * twenty days, and revised if a figure turns out wrong after that. + */ + private static final List STATUSES = List.of("DRAFT", "GENERATED", "CHECKED", "APPROVED", + "PROVISIONAL_SUBMITTED", "FINAL_SUBMITTED", "REVISED"); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + /** Saved returns, newest first. The stored snapshot is left out - the list does not need it. */ + @GET + public String retrieveAll(@QueryParam("formCode") final String formCode) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final StringBuilder sql = new StringBuilder(""" + SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end, + f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained, + f.rate_percentage, f.remarks, f.submitted_on, f.created_on, u.username AS created_by_name + FROM m_rbi_form_a_filing f + LEFT JOIN m_appuser u ON u.id = f.created_by + """); + final List args = new ArrayList<>(); + if (formCode != null && !formCode.isBlank()) { + sql.append(" WHERE f.form_code = ?"); + args.add(formCode); + } + sql.append(" ORDER BY f.reporting_date DESC, f.id DESC"); + + final List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + final List> out = new ArrayList<>(); + for (final Map row : rows) { + out.add(toSummary(row)); + } + return gson.toJson(out); + } + + /** One saved return, with the snapshot, so the screen can render exactly what was saved. */ + @GET + @Path("{filingId}") + public String retrieveOne(@PathParam("filingId") final Long filingId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end, + f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained, + f.rate_percentage, f.remarks, f.submitted_on, f.created_on, f.report_json, + u.username AS created_by_name + FROM m_rbi_form_a_filing f + LEFT JOIN m_appuser u ON u.id = f.created_by + WHERE f.id = ? + """, filingId); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found", + "Saved return " + filingId + " was not found."); + } + final Map row = rows.get(0); + final Map out = toSummary(row); + + // The snapshot is embedded as JSON rather than as a string, so the screen receives the same + // shape the live endpoint returns and can render it through the same code path. + final String json = (String) row.get("report_json"); + out.put("report", json == null || json.isBlank() ? null : gson.fromJson(json, JsonObject.class)); + return gson.toJson(out); + } + + /** + * Saves a generated return. Re-saving the same form for the same reporting date replaces that + * snapshot: regenerating a return for a date already saved is a correction of it, not a second + * statement for the same day. + */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + public String create(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String formCode = this.fromJsonHelper.extractStringNamed("formCode", root); + final LocalDate reportingDate = this.fromJsonHelper.extractLocalDateNamed("reportingDate", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + final Long officeId = this.fromJsonHelper.extractLongNamed("officeId", root); + + if (formCode == null || formCode.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.form.code", + "The form being saved must be named."); + } + if (reportingDate == null) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.reporting.date", + "A reporting date is required."); + } + + // The return itself, as the screen received it. Stored verbatim: the point of a saved return + // is that it does not change afterwards, so nothing here re-derives a figure from it. + final JsonObject report = root.getAsJsonObject().getAsJsonObject("report"); + if (report == null) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.report", + "The generated return must be supplied with the save."); + } + + final LocalDate periodStart = localDate(report, "fortnightStart"); + final LocalDate periodEnd = localDate(report, "fortnightEnd"); + final BigDecimal ndtl = decimal(report, "ndtlAmount"); + final BigDecimal required = decimal(report, "requiredAmount"); + final BigDecimal maintained = decimal(report, "maintainedAmount"); + final BigDecimal rate = decimal(report, "ratePercentage"); + BigDecimal average = null; + if (report.has("maintenance") && report.get("maintenance").isJsonObject()) { + average = decimal(report.getAsJsonObject("maintenance"), "average"); + } + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class, + formCode, java.sql.Date.valueOf(reportingDate)); + + if (existing != null && existing > 0) { + jdbcTemplate.update(""" + UPDATE m_rbi_form_a_filing + SET report_json = ?, ndtl_amount = ?, required_amount = ?, maintained_amount = ?, + average_maintained = ?, rate_percentage = ?, remarks = ?, office_id = ?, + period_start = ?, period_end = ?, fortnight_end = ?, + lastmodified_on = now(), lastmodified_by = ? + WHERE form_code = ? AND reporting_date = ? + """, gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId, + sqlDate(periodStart), sqlDate(periodEnd), sqlDate(periodEnd == null ? reportingDate : periodEnd), + this.context.authenticatedUser().getId(), formCode, java.sql.Date.valueOf(reportingDate)); + } else { + jdbcTemplate.update(""" + INSERT INTO m_rbi_form_a_filing + (form_code, reporting_date, fortnight_end, period_start, period_end, status, + report_json, ndtl_amount, required_amount, maintained_amount, average_maintained, + rate_percentage, remarks, office_id, created_on, created_by) + VALUES (?, ?, ?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?) + """, formCode, java.sql.Date.valueOf(reportingDate), + sqlDate(periodEnd == null ? reportingDate : periodEnd), sqlDate(periodStart), sqlDate(periodEnd), + gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId, + this.context.authenticatedUser().getId()); + } + return retrieveAll(formCode); + } + + /** + * Moves a saved return along its filing lifecycle. The snapshot is untouched: a return that has + * been checked and submitted must still show the figures it was checked on. + */ + @POST + @Path("{filingId}/status") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateStatus(@PathParam("filingId") final Long filingId, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String status = this.fromJsonHelper.extractStringNamed("status", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + + if (status == null || !STATUSES.contains(status)) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.unknown.status", + "Status must be one of " + STATUSES + "."); + } + final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE id = ?", + Integer.class, filingId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found", + "Saved return " + filingId + " was not found."); + } + + // The submission date is stamped when a return is actually sent, and only then - an earlier + // stage has nothing to date, and re-stamping on a later stage would lose when it was sent. + final boolean submitted = "PROVISIONAL_SUBMITTED".equals(status) || "FINAL_SUBMITTED".equals(status); + jdbcTemplate.update(""" + UPDATE m_rbi_form_a_filing + SET status = ?, + remarks = COALESCE(?, remarks), + submitted_on = CASE WHEN ? THEN COALESCE(submitted_on, CURRENT_DATE) ELSE submitted_on END, + lastmodified_on = now(), lastmodified_by = ? + WHERE id = ? + """, status, remarks, submitted, this.context.authenticatedUser().getId(), filingId); + return retrieveAll(null); + } + + /** Discards a saved return. Only ever removes the snapshot; nothing in the ledger is touched. */ + @DELETE + @Path("{filingId}") + public String delete(@PathParam("filingId") final Long filingId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final Integer removed = jdbcTemplate.update("DELETE FROM m_rbi_form_a_filing WHERE id = ?", filingId); + if (removed == 0) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found", + "Saved return " + filingId + " was not found."); + } + return retrieveAll(null); + } + + private Map toSummary(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("id", row.get("id")); + out.put("formCode", row.get("form_code")); + out.put("reportingDate", str(row.get("reporting_date"))); + out.put("periodStart", str(row.get("period_start"))); + out.put("periodEnd", str(row.get("period_end"))); + out.put("status", row.get("status")); + out.put("ndtlAmount", row.get("ndtl_amount")); + out.put("requiredAmount", row.get("required_amount")); + out.put("maintainedAmount", row.get("maintained_amount")); + out.put("averageMaintained", row.get("average_maintained")); + out.put("ratePercentage", row.get("rate_percentage")); + out.put("remarks", row.get("remarks")); + out.put("submittedOn", str(row.get("submitted_on"))); + out.put("createdOn", str(row.get("created_on"))); + out.put("createdBy", row.get("created_by_name")); + return out; + } + + private static String str(final Object value) { + return value == null ? null : String.valueOf(value); + } + + private static java.sql.Date sqlDate(final LocalDate date) { + return date == null ? null : java.sql.Date.valueOf(date); + } + + private static BigDecimal decimal(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + return object.get(field).getAsBigDecimal(); + } + + private static LocalDate localDate(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + final String value = object.get(field).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java index 36b1a0d19..9b07890e5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -228,13 +228,18 @@ public class RbiReserveConfigApiResource { private static final List RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND"); /** The four treatments a head can be given, each naming a branch the returns already have. */ - private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO"); + private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET", + "MEMO"); private static final Map USAGE_DESCRIPTIONS = new LinkedHashMap<>(); static { USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head"); USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL"); - USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward what is maintained against the requirement"); + USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward CRR maintained (Form 1)"); + // Separate from MAINTAINED because the two returns admit different assets: CRR is met in + // cash and balances, SLR in cash, gold and unencumbered approved securities. One usage for + // both would let a head meant for one return quietly satisfy the other. + USAGE_DESCRIPTIONS.put("SLR_ASSET", "Counted toward SLR maintained (Form 9)"); USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total"); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java index 2f641343b..ad11ff8f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -117,6 +117,13 @@ public class RbiReserveReturnApiResource { private static final String USAGE_LIABILITY = "LIABILITY"; private static final String USAGE_ASSET_NET = "ASSET_NET"; private static final String USAGE_MAINTAINED = "MAINTAINED"; + /** + * Counts toward SLR rather than CRR. The two are separate because the returns admit different + * assets: CRR is met in cash and balances with banks, SLR in cash, gold and unencumbered + * approved securities. A single "maintained" usage would let a head meant for one return + * quietly satisfy the other. + */ + private static final String USAGE_SLR_ASSET = "SLR_ASSET"; private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; @@ -256,21 +263,29 @@ public class RbiReserveReturnApiResource { // average across the fortnight AND a daily minimum on every single day of it, so a bank // that held nothing for thirteen days and topped up on the last one fails even though // its closing position looks ample. - final LocalDate fortnightStart = LocalDate.parse((String) response.get("fortnightStart")); - final LocalDate fortnightEnd = LocalDate.parse((String) response.get("fortnightEnd")); + // The maintenance table covers the whole calendar month, not the fortnight alone: a + // reviewer comparing two filings of the same month otherwise has to stitch two + // fortnights together by hand to see how the position moved across it. + final LocalDate fortnightStart = reportingDate.withDayOfMonth(1); + final LocalDate fortnightEnd = reportingDate.with(TemporalAdjusters.lastDayOfMonth()); final List maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS")); maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED)); // Only up to the reporting date: days after it have not happened, and averaging an // unfinished fortnight over its whole length would understate what is held so far. final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd; - final Map daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles); + final Map daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles, + required); final BigDecimal average = (BigDecimal) daily.get("average"); final BigDecimal lowest = (BigDecimal) daily.get("lowest"); final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2, RoundingMode.HALF_UP); + daily.put("periodStart", fortnightStart.toString()); + daily.put("periodEnd", fortnightEnd.toString()); + daily.put("periodLabel", fortnightStart.getMonth().getDisplayName(java.time.format.TextStyle.FULL, + java.util.Locale.ENGLISH) + " " + fortnightStart.getYear()); daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd)); daily.put("requiredAverage", required); daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT); @@ -398,31 +413,49 @@ public class RbiReserveReturnApiResource { } lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd)); lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd)); + // The heads that DO count toward SLR - gold, approved securities, eligible cash - each on + // its own line, so what makes up the maintained figure is visible rather than a single total + // a reader has to take on trust. + for (final CustomHead head : heads) { + if (USAGE_SLR_ASSET.equals(head.usage) && !inNamedSection(head)) { + lines.add(line(head.label, head.figure, monthEnd)); + } + } // Shown for completeness of the month-end liquid position; not added into SLR maintained. - // A custom MAINTAINED head IS added: it was declared as counting toward the requirement, - // and Form 9's requirement is the SLR one. lines.add(line("Cash in hand (memo)", cash, monthEnd)); for (final CustomHead head : heads) { - if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { - // Every such head is a memo on THIS return, including one marked MAINTAINED: only - // SLR-eligible investments count toward SLR, so the label must not suggest the - // figure was added into the maintained total when it was not. - lines.add(line(head.label + " (memo)", head.figure, monthEnd)); + if (USAGE_LIABILITY.equals(head.usage) || USAGE_ASSET_NET.equals(head.usage) + || USAGE_SLR_ASSET.equals(head.usage) || inNamedSection(head)) { + continue; } + // Every remaining head is a memo on THIS return, including one marked MAINTAINED: that + // usage means CRR, so the label must not suggest the figure was added into SLR. + lines.add(line(head.label + " (memo)", head.figure, monthEnd)); } response.put("lines", lines); - final boolean computable = rate != null && dtl.configured && investments.configured; + // Form A's Memorandum and Annexures appear on Form 9 too - the same heads, under the same + // headings, so a reviewer reading either return finds them in the same place. + response.put("memorandum", sectionOf(heads, "Memorandum - ")); + response.put("annexureA", sectionOf(heads, "Annexure A - ")); + response.put("annexureB", sectionOf(heads, "Annexure B - ")); + + // Heads the bank declared as SLR assets - gold, approved securities, eligible cash. Summed + // here rather than on the built-in investments role alone, because SLR is maintained in + // more than government securities and a bank holding gold has no other place to state it. + final BigDecimal slrHeads = totalOf(heads, USAGE_SLR_ASSET); + final boolean anySlrAsset = investments.configured || hasUsage(heads, USAGE_SLR_ASSET); + + final boolean computable = rate != null && dtl.configured && anySlrAsset; response.put("computable", computable); if (computable) { final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); - // SLR admits government securities, not cash. A head marked MAINTAINED is therefore NOT - // added here the way it is on Form 1: it was declared as counting toward a requirement, - // and on Form 9 only SLR-eligible investments do. Adding it would let a cash-like head - // configured for CRR inflate the SLR position, which is the error this return exists to - // detect. A bank with more eligible securities maps them to SLR_INVESTMENTS. - final BigDecimal held = investments.amount; + // A head marked MAINTAINED is deliberately NOT added: that usage means CRR, which is met + // in cash and balances with banks. Letting it count here would allow a head configured + // for Form 1 to inflate the SLR position - exactly the error this return exists to + // detect. Only SLR_ASSET heads and the built-in investments role count. + final BigDecimal held = investments.amount.add(slrHeads); response.put("dtlAmount", liabilities); response.put("demandLiabilities", dtl.configured ? demand : null); response.put("timeLiabilities", dtl.configured ? time : null); @@ -434,7 +467,11 @@ public class RbiReserveReturnApiResource { response.put("compliant", held.compareTo(required) >= 0); response.put("maintainedPercentage", percentageOf(held, ndtl)); } - response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments)); + // The investments role is only reported as missing when nothing else stands in for it: a + // bank that maintains SLR in gold, or in securities mapped to its own heads, has configured + // the return even though that one built-in role is empty. + final List slrUnmet = anySlrAsset ? unmet(rate, "SLR", dtl) : unmet(rate, "SLR", dtl, investments); + response.put("unmetRequirements", slrUnmet); return gson.toJson(response); } @@ -645,6 +682,11 @@ public class RbiReserveReturnApiResource { } /** The total a set of custom heads contributes under one treatment. */ + /** Whether any head was declared with one usage, so "nothing mapped" can be told from "nil". */ + private static boolean hasUsage(final List heads, final String usage) { + return heads.stream().anyMatch(h -> usage.equals(h.usage) && h.figure.configured); + } + private static BigDecimal totalOf(final List heads, final String usage) { BigDecimal total = BigDecimal.ZERO; for (final CustomHead head : heads) { @@ -753,7 +795,7 @@ public class RbiReserveReturnApiResource { * excluding those days would let a fortnight ending on a long weekend average away a shortfall. */ private Map dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId, - final List maintainedRoles) { + final List maintainedRoles, final BigDecimal required) { final List> days = new ArrayList<>(); BigDecimal total = BigDecimal.ZERO; BigDecimal lowest = null; @@ -771,7 +813,11 @@ public class RbiReserveReturnApiResource { } final Map row = new LinkedHashMap<>(); row.put("date", day.toString()); + // Each day carries the requirement it was measured against and the gap, so a reader can + // see WHY a day passed or failed without recomputing the percentage themselves. + row.put("required", required); row.put("amount", held); + row.put("surplus", held.subtract(required)); days.add(row); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java new file mode 100644 index 000000000..82b87cddc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class SaveRbiFormAReturnJobConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired + private RbiFormAAutoSaveService rbiFormAAutoSaveService; + + @Bean + public SaveRbiFormAReturnTasklet saveRbiFormAReturnTasklet() { + return new SaveRbiFormAReturnTasklet(rbiFormAAutoSaveService); + } + + @Bean + protected Step saveRbiFormAReturnStep() { + return new StepBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository) + .tasklet(saveRbiFormAReturnTasklet(), transactionManager).build(); + } + + @Bean + public Job saveRbiFormAReturnJob() { + return new JobBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository).start(saveRbiFormAReturnStep()) + .incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java new file mode 100644 index 000000000..41ba2c192 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; + +/** + * Runs the Form A auto-save. The service decides whether today actually closes a fortnight, so the + * tasklet stays a thin trigger and the rule lives in one place. + */ +@RequiredArgsConstructor +public class SaveRbiFormAReturnTasklet implements Tasklet { + + private final RbiFormAAutoSaveService service; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) { + service.saveCurrentFortnight(); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java new file mode 100644 index 000000000..ab09e9143 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java @@ -0,0 +1,153 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.service; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.rbireserve.api.RbiReserveReturnApiResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Keeps a copy of each fortnight's Form A without anyone having to remember to. + * + * Form 1 is filed twice a month, and what it reports is a position that cannot be recovered later: a + * backdated journal entry, a re-mapped GL account or a corrected rate all move figures that were + * already reported, so a fortnight nobody saved is a fortnight whose filed statement no longer + * exists anywhere. The job runs on the days the fortnight closes and stores the return as it stands + * at that close. + * + * It saves as DRAFT and nothing more. Checking, approving and submitting stay human acts - the job + * exists so the figures survive, not so a return files itself. + * + * It never overwrites: a fortnight already saved, by hand or by an earlier run, is left exactly as + * it is. Re-running the job must not replace a statement somebody has already reviewed. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RbiFormAAutoSaveService { + + private static final String FORM_CODE = "FORM_1"; + + private final JdbcTemplate jdbcTemplate; + private final RbiReserveReturnApiResource returnApiResource; + private final Gson gson = new Gson(); + + /** + * Saves the fortnight that closes on the business date, if it has not been saved already. + * + * Scheduled for the 15th and the last day of each month, but it does not trust the schedule: it + * checks the date itself, so a mis-set cron or a manual run on the wrong day cannot file a + * fortnight that has not closed. + */ + @Transactional + public void saveCurrentFortnight() { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate monthEnd = today.with(TemporalAdjusters.lastDayOfMonth()); + + final boolean fortnightCloses = today.getDayOfMonth() == 15 || today.equals(monthEnd); + if (!fortnightCloses) { + log.debug("RBI Form A auto-save: {} is not a fortnight end, nothing to save.", today); + return; + } + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class, + FORM_CODE, java.sql.Date.valueOf(today)); + if (existing != null && existing > 0) { + log.info("RBI Form A auto-save: {} is already saved, leaving it untouched.", today); + return; + } + + final JsonObject report; + try { + // Computed through the same endpoint the screen uses, so an auto-saved return and one + // saved by hand cannot come out differently. + report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class); + } catch (final RuntimeException e) { + // A return that cannot be computed is not an error worth failing the batch over - an + // unmapped role or a missing rate is a configuration gap, and the next run picks it up + // once it is filled. Logged rather than thrown so one tenant's gap does not stop others. + log.warn("RBI Form A auto-save: could not compute the return for {}: {}", today, e.getMessage()); + return; + } + + if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) { + log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.", + today); + return; + } + + final LocalDate periodStart = date(report, "fortnightStart"); + final LocalDate periodEnd = date(report, "fortnightEnd"); + BigDecimal average = null; + if (report.has("maintenance") && report.get("maintenance").isJsonObject()) { + average = decimal(report.getAsJsonObject("maintenance"), "average"); + } + + jdbcTemplate.update(""" + INSERT INTO m_rbi_form_a_filing + (form_code, reporting_date, fortnight_end, period_start, period_end, status, report_json, + ndtl_amount, required_amount, maintained_amount, average_maintained, rate_percentage, + remarks, created_on) + VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now()) + """, FORM_CODE, java.sql.Date.valueOf(today), + java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd), + gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"), + decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"), + "Saved automatically at the close of the fortnight."); + + log.info("RBI Form A auto-save: saved the return for {} as a draft.", today); + } + + /** Offices the return could be scoped to. Unused today - the job saves the consolidated return. */ + @SuppressWarnings("unused") + private List> offices() { + return jdbcTemplate.queryForList("SELECT id, name FROM m_office ORDER BY id"); + } + + private static java.sql.Date sqlDate(final LocalDate date) { + return date == null ? null : java.sql.Date.valueOf(date); + } + + private static BigDecimal decimal(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + return object.get(field).getAsBigDecimal(); + } + + private static LocalDate date(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + final String value = object.get(field).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index b8e38a310..56a28f9a0 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -132,5 +132,8 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml new file mode 100644 index 000000000..be760cbf3 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml new file mode 100644 index 000000000..50efebc34 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml @@ -0,0 +1,72 @@ + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Save RBI Form A Return' + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml new file mode 100644 index 000000000..650b39c02 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage; + ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage + CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'MEMO')); + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key = 'FORMA_SLR_GOLD' + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role + WHERE config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', + 'FORMA_INV_OTHER_APPROVED') + + + + + + + + config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', 'FORMA_INV_OTHER_APPROVED') + AND role_usage = 'MEMO' + + + + + From 24d3bf83bbff02cebf5fac4b498542a611bd5d1a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 17:53:25 +0530 Subject: [PATCH 19/39] fix: correct Current-product OD columns, PortfolioProductType mapping, and duplicate Bank Transfer voucher - Add migration 0585 to add the OD-scheme columns to the correctly-named pending-edit shadow table (m_saving_product_temp, singular), fixing migration 2033's typo that made it a permanent no-op and broke editing any Current product with OD fields set. - Fix PortfolioProductType.fromInt() to switch on each constant's declared value instead of declaration position, which silently mis-mapped ids 3-5 and NPE'd on id 6 (CURRENT), crashing Journal Entries for Current-account GL rows. - Stop generating a second Bank Transfer voucher at approve time; reuse the one already created at raise/submit time instead, eliminating duplicate vouchers for the same transfer under two different users' names. Co-Authored-By: Claude Sonnet 5 --- .../portfolio/PortfolioProductType.java | 14 +++- .../ClerkDisbursementRequestApiResource.java | 10 ++- .../tenant/final-changelog-tenant.xml | 1 + ...rent_product_od_scheme_pending_columns.xml | 65 +++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java index fc6f313f3..2f4895e09 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java @@ -55,17 +55,25 @@ public enum PortfolioProductType { return null; } + // Matched against each constant's own declared value above, not its declaration position -- + // those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written + // by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING) + // returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES, + // and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry + // (entity_type_enum=6) reached this on the Journal Entries screen. switch (v) { case 1: return LOAN; case 2: return SAVING; case 3: - return CLIENT; - case 4: return PROVISIONING; - case 5: + case 4: return SHARES; + case 5: + return CLIENT; + case 6: + return CURRENT; default: return null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 56f55e3ba..00e292a9a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -631,7 +631,15 @@ public class ClerkDisbursementRequestApiResource { return ps; }, btLogKh); final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue(); - final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal); + // submit() already raised this request's voucher (every request type does, so it + // shows in My Vouchers the moment a clerk submits it, before any checker acts) -- + // minting a second one here for Bank Transfer specifically double-counted the same + // transfer under two different names, "Entered by" whoever raised it AND whoever + // approved it. Reuse that voucher; only fall back to raising a fresh one if this + // request somehow reached approval without it (defensive, not the normal path). + final Object existingVoucherNo = record.get("voucher_no"); + final String btVoucherNo = existingVoucherNo != null ? (String) existingVoucherNo + : generateBtVoucherNo(btLogId, currentUser, finalBtTotal); jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId); } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 56a28f9a0..bbb450357 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -136,4 +136,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml new file mode 100644 index 000000000..071cc2974 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From e7417d16a6a52365dca95047011d41df0abbb972 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 18:35:11 +0530 Subject: [PATCH 20/39] chore: renumber migration 0585 to 0587 ahead of feature/bugfix merge feature/bugfix independently used 0585/0586 for unrelated migrations (loan COB catch-up, system module checker permissions), colliding with this session's own 0585 file. Renumbered to the first free slot after theirs so both sets of changesets coexist without ambiguity; content and semantics are unchanged, only the file name and changeset ids moved. Co-Authored-By: Claude Sonnet 5 --- .../resources/db/changelog/tenant/final-changelog-tenant.xml | 2 +- ...=> 0587_fix_current_product_od_scheme_pending_columns.xml} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename fineract-provider/src/main/resources/db/changelog/tenant/parts/{0585_fix_current_product_od_scheme_pending_columns.xml => 0587_fix_current_product_od_scheme_pending_columns.xml} (96%) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index bbb450357..7727710c9 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -136,5 +136,5 @@ - + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml similarity index 96% rename from fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml rename to fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml index 071cc2974..5e2372200 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml @@ -31,7 +31,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd"> - + @@ -46,7 +46,7 @@ - + From 1408bddf58215ae6e340548260d7263eb56ea561 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 29 Sep 2026 15:36:18 +0530 Subject: [PATCH 21/39] feat(security): auto-mint the first platform-operator API key on deploy, disable minting one over HTTP A fresh deployment's m_api_client table starts empty, and reaching #/platform-admin/menu-access requires a valid X-Api-Key -- with no key, every tenant 401s identically (this is by design, not tenant-specific). Add DefaultApiClientBootstrapper: an ApplicationRunner that mints exactly one random key on first startup (guarded by "table still empty", so later restarts are a no-op) and logs the raw value once to the server's own application log -- never to the database, never over HTTP. Disable ApiClientAdminApiResource's POST /v1/apiclients entirely: it only required ALL_FUNCTIONS, a per-tenant role grant, so any bank's own super user (or a misconfigured role, as "Clerk" was found to carry it in two tenants this session) could mint themselves a platform-wide operator key and bypass the exact ceiling TenantMenuAccessApiResource exists to enforce. New keys past the first are now a direct m_api_client insert only. Co-Authored-By: Claude Sonnet 5 --- .../core/config/SecurityConfig.java | 8 +- .../apiclient/ApiClientAdminApiResource.java | 34 ++++---- .../DefaultApiClientBootstrapper.java | 83 +++++++++++++++++++ 3 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index 4e739e895..73e2f73f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -165,9 +165,11 @@ public class SecurityConfig { // envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler. .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll() // Client account summary, third-party callers — a partner integration calls this - // with an X-Api-Key header instead of a Fineract username/password (issued via - // POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the - // outer gate stays open for this one narrow GET path, and + // with an X-Api-Key header instead of a Fineract username/password. The key itself + // is issued by inserting directly into m_api_client in the tenant-store database + // (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled, + // since any bank's own super user reaching it could mint a platform-wide key). Same + // posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and // ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates // the caller (ApiClientCredentialService checks the key's hash, active flag and // IP allowlist) — a request with no key and no Fineract login still gets nothing, diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java index 0c469621a..a6bca7aa2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java @@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; @@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; -import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; /** * Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates. - * This resource itself requires a normal Fineract login, same as everything else -- ordinary - * username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted - * key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that - * key in the first place is not something to hand to just anyone. + * List/deactivate/reactivate still require a normal Fineract login restricted to a super user + * (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose. + * + * {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole + * point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the + * "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access + * ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could + * call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one + * that authenticates against every tenant, not just their own -- completely bypassing the ceiling this + * key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine + * (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly + * the escalation path being closed. New keys are issued the same way this fix issued the first one: a + * direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by + * whoever already has direct database access -- never over HTTP. */ @Path("/v1/apiclients") @Component @@ -55,20 +63,18 @@ public class ApiClientAdminApiResource { private final PlatformSecurityContext context; private final ApiClientCredentialService credentialService; - private final FromJsonHelper fromJsonHelper; private final Gson gson = new GsonBuilder().serializeNulls().create(); + /** Disabled -- see the class comment. Minting a key is a direct-database operation only. */ @POST @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public String create(final String body) { - final AppUser user = requireSuperUser(); - final JsonElement root = fromJsonHelper.parse(body); - final String clientName = fromJsonHelper.extractStringNamed("clientName", root); - final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root); - - final Map result = credentialService.createClient(clientName, allowedIps, user.getId()); - return gson.toJson(result); + requireSuperUser(); + throw new WebApplicationException(Response.status(403) + .entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. " + + "Insert directly into m_api_client in the tenant-store database instead.\"}") + .type(MediaType.APPLICATION_JSON).build()); } @GET diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java new file mode 100644 index 000000000..076a56554 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.apiclient; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * Mints the very first platform-operator API key on a brand-new deployment, once, at startup -- the + * counterpart to {@link ApiClientAdminApiResource} deliberately disabling {@code POST /v1/apiclients}. + * With that HTTP path closed for security (see that class's javadoc), a fresh {@code m_api_client} + * table with zero rows would otherwise leave a first-time deployer with no way at all to reach + * {@code #/platform-admin/menu-access} except hand-writing SQL themselves -- this closes that gap + * without reopening the escalation path the disabled endpoint existed to close. + * + * Runs as an {@link ApplicationRunner}, which Spring Boot only invokes after the context has fully + * refreshed -- i.e. strictly after {@code TenantDatabaseUpgradeService} (an {@code InitializingBean}) + * has already created {@code m_api_client} via migration 0011. The guard is simply "does the table have + * any row yet": on every startup after the first, this is a no-op, so it never mints a second key + * behind anyone's back. + * + * The raw key is deliberately never written to the database (only its hash is, same as any other key) + * and never returned over HTTP -- it is logged ONCE, here, to this server's own application log. That + * is a real security boundary, not a formality: reading it requires access to the server's own log + * output, which is a materially different (and much smaller) trust boundary than "reachable over the + * network," which is exactly why the HTTP minting path was closed instead of left open. + */ +@Component +@RequiredArgsConstructor +public class DefaultApiClientBootstrapper implements ApplicationRunner { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultApiClientBootstrapper.class); + + private final ApiClientCredentialService credentialService; + + @Override + public void run(final ApplicationArguments args) { + if (!credentialService.listClients().isEmpty()) { + return; + } + final Map created = credentialService.createClient("Default Platform Operator (auto-created on first startup)", + null, null); + final String rawKey = (String) created.get("apiKey"); + + LOG.warn(""" + + ================================================================================ + CBSNexora: first-run platform operator API key created. + + This key unlocks #/platform-admin/menu-access on this deployment. It is stored + only as a hash in m_api_client -- this is the ONLY place the raw value is ever + shown. Copy it now; it cannot be recovered later, only reissued as a new key. + + API Key: {} + + Minting a replacement over HTTP is deliberately disabled (see + ApiClientAdminApiResource) -- a further key can only be inserted directly into + m_api_client in the tenant-store database, the same way this one was. + ================================================================================ + """, rawKey); + } +} From 9436eab1298da252ebe6f59cefdc1b2d3f2c5ea9 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 29 Sep 2026 17:01:07 +0530 Subject: [PATCH 22/39] feat(security): seed a fixed default platform API key via migration instead of a random one at runtime Replaces the previous DefaultApiClientBootstrapper (ApplicationRunner minting a random key on first startup) with a plain Liquibase seed on the tenant-store database: one fixed, well-known api_key_hash row, inserted once via migration 0012, same on every deployment and every tenant -- exactly like this fork already seeds default codes/permissions, rather than generating a secret at runtime. The trade-off is explicit in the migration's own comment: a fixed key checked into source is a bootstrap convenience, not a secret, and any deployment beyond local/dev must rotate it (insert a new hash, deactivate this row) before going live -- rotation is a direct m_api_client write, same as the seed itself, since POST /v1/apiclients stays disabled. Co-Authored-By: Claude Sonnet 5 --- .../DefaultApiClientBootstrapper.java | 83 ------------------- .../tenant-store/changelog-tenant-store.xml | 1 + .../0012_seed_default_platform_api_client.xml | 62 ++++++++++++++ 3 files changed, 63 insertions(+), 83 deletions(-) delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java deleted file mode 100644 index 076a56554..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.infrastructure.security.apiclient; - -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; - -/** - * Mints the very first platform-operator API key on a brand-new deployment, once, at startup -- the - * counterpart to {@link ApiClientAdminApiResource} deliberately disabling {@code POST /v1/apiclients}. - * With that HTTP path closed for security (see that class's javadoc), a fresh {@code m_api_client} - * table with zero rows would otherwise leave a first-time deployer with no way at all to reach - * {@code #/platform-admin/menu-access} except hand-writing SQL themselves -- this closes that gap - * without reopening the escalation path the disabled endpoint existed to close. - * - * Runs as an {@link ApplicationRunner}, which Spring Boot only invokes after the context has fully - * refreshed -- i.e. strictly after {@code TenantDatabaseUpgradeService} (an {@code InitializingBean}) - * has already created {@code m_api_client} via migration 0011. The guard is simply "does the table have - * any row yet": on every startup after the first, this is a no-op, so it never mints a second key - * behind anyone's back. - * - * The raw key is deliberately never written to the database (only its hash is, same as any other key) - * and never returned over HTTP -- it is logged ONCE, here, to this server's own application log. That - * is a real security boundary, not a formality: reading it requires access to the server's own log - * output, which is a materially different (and much smaller) trust boundary than "reachable over the - * network," which is exactly why the HTTP minting path was closed instead of left open. - */ -@Component -@RequiredArgsConstructor -public class DefaultApiClientBootstrapper implements ApplicationRunner { - - private static final Logger LOG = LoggerFactory.getLogger(DefaultApiClientBootstrapper.class); - - private final ApiClientCredentialService credentialService; - - @Override - public void run(final ApplicationArguments args) { - if (!credentialService.listClients().isEmpty()) { - return; - } - final Map created = credentialService.createClient("Default Platform Operator (auto-created on first startup)", - null, null); - final String rawKey = (String) created.get("apiKey"); - - LOG.warn(""" - - ================================================================================ - CBSNexora: first-run platform operator API key created. - - This key unlocks #/platform-admin/menu-access on this deployment. It is stored - only as a hash in m_api_client -- this is the ONLY place the raw value is ever - shown. Copy it now; it cannot be recovered later, only reissued as a new key. - - API Key: {} - - Minting a replacement over HTTP is deliberately disabled (see - ApiClientAdminApiResource) -- a further key can only be inserted directly into - m_api_client in the tenant-store database, the same way this one was. - ================================================================================ - """, rawKey); - } -} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml index 852255252..0c4578281 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml @@ -32,4 +32,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml new file mode 100644 index 000000000..a75be07a2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + SELECT COUNT(*) FROM m_api_client + WHERE api_key_hash = '9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd' + + + + + + + + + + + + + + From 82336cbe98d1ce7253177cf71cbf8b4f6fcfc9cc Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 30 Sep 2026 12:00:59 +0530 Subject: [PATCH 23/39] fix: remove illegal -- from migration 0012's XML comment Same mistake as an earlier migration this session: a literal "--" inside an XML comment is illegal per the XML spec and crashes the entire backend startup (Liquibase's XML parser rejects the whole changelog, not just this file). Reworded the prose to avoid it. Co-Authored-By: Claude Sonnet 5 --- .../0012_seed_default_platform_api_client.xml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml index a75be07a2..950b18571 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml @@ -27,17 +27,17 @@ #/platform-admin/menu-access works out of the box the first time the app is stood up, without anyone needing shell/DB access to mint the first key by hand. It is deliberately the SAME raw value on every deployment/tenant (requested behaviour) rather than a random - one generated per install -- the trade-off, on purpose: this value is visible to anyone who - can read this source file, exactly like a default admin password shipped in a seed script. - It is a bootstrap convenience, not a secret. Rotate it for any deployment that is not purely - local/dev: insert a new row with a real random key's hash (see ApiClientCredentialService's - hashing) and either deactivate or DELETE this row -- POST /v1/apiclients is intentionally - disabled (see ApiClientAdminApiResource), so rotation is a direct m_api_client write, same - as this seed. + one generated per install: this is a deliberate trade-off, since this value is visible to + anyone who can read this source file, exactly like a default admin password shipped in a + seed script. It is a bootstrap convenience, not a secret. Rotate it for any deployment that + is not purely local/dev: insert a new row with a real random key's hash (see + ApiClientCredentialService's hashing) and either deactivate or DELETE this row. POST + /v1/apiclients is intentionally disabled (see ApiClientAdminApiResource), so rotation is a + direct m_api_client write, same as this seed. Raw key (only shown here, since it is fixed and not a secret to rotate away from): cbsx_default_platform_operator_key_2026 - api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase -- matching + api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase, matching ApiClientCredentialService#sha256Hex. --> From ce8bb7e5ca0a805b32b741df9579da914d60e756 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 30 Sep 2026 12:39:58 +0530 Subject: [PATCH 24/39] fix(products): named GL account changes (e.g. Savings Interest Payable/Paid) were silently lost on a pending edit Root cause: ProductPendingEditFieldMapper.populateCoreFields() captured otherGlMappings with stringValueOfParameterNamed(), which only handles a JSON string primitive. The maker's edit actually submits otherGlMappings as a nested {assets,liabilities,expenses,income} object -- the same shape SavingsProduct#update() already reads correctly via jsonFragment() -- so the pending-edit row was staged with this field blank every time, regardless of what the maker changed. Two consequences, both now fixed: - The checker's diff dialog showed "No changes to review" even when a GL account mapping (e.g. Savings/Current/FD/RD Interest Payable or Paid) was genuinely changed, since the submitted value never made it into proposedChanges. - Approving the edit replayed a payload with no otherGlMappings at all, so the GL account change was silently dropped rather than applied -- the edit showed as approved, but the product's accounting mapping never actually moved. Fixed the write side (jsonFragment instead of stringValueOfParameterNamed) and the read-side replay (writeCoreFieldsToJson now re-adds the stored value as a parsed JSON element via the new putJsonFragment, not a quoted string, which would have double-encoded it on replay). Shared by Savings, Current, FD and RD products, since they all go through this one mapper. Co-Authored-By: Claude Sonnet 5 --- .../domain/ProductPendingEditFieldMapper.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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 index 3043ceff6..30ce2a490 100644 --- 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 @@ -158,7 +158,15 @@ public final class ProductPendingEditFieldMapper { present.add("taxGroupId"); } if (command.hasParameter("otherGlMappings")) { - f.setOtherGlMappings(command.stringValueOfParameterNamed("otherGlMappings")); + // otherGlMappings arrives as a nested JSON object ({assets:[...], liabilities:[...], + // expenses:[...]}), the same shape SavingsProduct#update() reads via jsonFragment() -- + // NOT a JSON string primitive. stringValueOfParameterNamed() only handles the latter and + // silently came back blank for the former, so every "Savings Interest Payable"/"Savings + // Interest Paid" (and other named GL account) edit was staged with an empty value: it + // never reached the pending-edit row, so the checker's diff had nothing to show and + // approving replayed no GL account change at all. jsonFragment() is the same call the + // live (non-pending) update path already uses for this exact parameter. + f.setOtherGlMappings(command.jsonFragment("otherGlMappings")); present.add("otherGlMappings"); } if (command.hasParameter("isDormancyTrackingActive")) { @@ -355,7 +363,7 @@ public final class ProductPendingEditFieldMapper { putNumber(root, submitted, "minBalanceForInterestCalculation", f.getMinBalanceForInterestCalculation()); putBoolean(root, submitted, "withHoldTax", f.getWithHoldTax()); putNumber(root, submitted, "taxGroupId", f.getTaxGroupId()); - putString(root, submitted, "otherGlMappings", f.getOtherGlMappings()); + putJsonFragment(root, submitted, "otherGlMappings", f.getOtherGlMappings()); putBoolean(root, submitted, "isDormancyTrackingActive", f.getIsDormancyTrackingActive()); putNumber(root, submitted, "daysToInactive", f.getDaysToInactive()); putNumber(root, submitted, "daysToDormancy", f.getDaysToDormancy()); @@ -733,6 +741,24 @@ public final class ProductPendingEditFieldMapper { } } + /** + * Same as {@link #putString}, but for a value that is itself raw JSON (an object/array captured + * via {@code JsonCommand#jsonFragment}) rather than a plain string. {@code addProperty} would wrap + * it in quotes as an escaped string literal, which {@code jsonFragment} on the replayed command + * would then hand back as that literal quoted text instead of the original structure -- breaking + * the downstream `other_gl_mappings::jsonb` cast this field's value round-trips through. + */ + private static void putJsonFragment(final JsonObject root, final Set submitted, final String name, final String rawJson) { + if (!submitted.contains(name)) { + return; + } + if (rawJson == null || rawJson.isBlank()) { + root.add(name, com.google.gson.JsonNull.INSTANCE); + return; + } + root.add(name, com.google.gson.JsonParser.parseString(rawJson)); + } + private static void putNumber(final JsonObject root, final Set submitted, final String name, final Number value) { if (submitted.contains(name)) { root.addProperty(name, value); From 2afd4aaf839c8e10ec917d746221fbf480c8af26 Mon Sep 17 00:00:00 2001 From: anshul Date: Wed, 30 Sep 2026 17:07:16 +0530 Subject: [PATCH 25/39] Enforce same-user maker-checker restriction --- .../service/CommandSourceService.java | 30 +++----- ...CommandSourceWritePlatformServiceImpl.java | 28 +++++-- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 14 +++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- .../tenant/final-changelog-tenant.xml | 1 + ...ally_enabled_maker_checker_permissions.xml | 73 +++++++++++++++++++ .../service/CommandSourceServiceTest.java | 18 +++-- 12 files changed, 188 insertions(+), 48 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index 591e46473..1f8ef69fa 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -137,7 +137,16 @@ public class CommandSourceService { String permission = commandSource.getPermissionCode(); boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission); if (isMakerChecker || result.isRollbackTransaction()) { - if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) { + // isApprovedByChecker is only ever true here for a genuine checker approval + // (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run + // validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user + // editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker + // genuinely does not apply there). On the ORIGINAL submission this is always false and `user` + // here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any + // other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's + // request without needing the specific _CHECKER permission, it must never let a maker + // auto-approve their own submission just by holding it. + if (isApprovedByChecker) { commandSource.markAsChecked(user); } else { if (commandSource.isSanitized()) { @@ -151,25 +160,6 @@ public class CommandSourceService { return result; } - private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) { - if (!user.isCheckerSuperUser()) { - return false; - } - if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName()) - || "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName()) - || "CHARGE".equalsIgnoreCase(commandSource.getEntityName()) - || "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "USER".equalsIgnoreCase(commandSource.getEntityName()) - || "ROLE".equalsIgnoreCase(commandSource.getEntityName())) { - return false; - } - return true; - } - private void sanitizeJson(@NonNull CommandSource commandSource, Set sanitizeKeys) { if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { return; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java index 5406ac5b4..f4c7926ba 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java @@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio private final FromJsonHelper fromApiJsonHelper; private final CommandProcessingService processAndLogCommandService; private final SchedulerJobRunnerReadService schedulerJobRunnerReadService; + // No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user + // can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no + // longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a + // live, independently-configurable global switch (System -> Configurations); deleting this field, + // and by extension deciding that switch should stop existing altogether, is a separate decision + // from closing this specific self-approval loophole. private final ConfigurationDomainService configurationService; @Override @@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio AppUser appUser = this.context.authenticatedUser(); String permissionCode = commandSource.getPermissionCode(); appUser.validateHasCheckerPermissionTo(permissionCode); - if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) { - AppUser maker = commandSource.getMaker(); - if (maker == null) { - throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); - } - if (Objects.equals(appUser.getId(), maker.getId())) { - throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user."); - } + // Maker-identity rule: the user who submitted a command may never be the one who approves + // (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that + // permission still lets its holder action a DIFFERENT user's request without needing the + // specific _CHECKER permission - see validateHasCheckerPermissionTo() above - it just no + // longer lets them action their own) and not overridable by the enable-same-maker-checker + // global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that + // configuration is left in place and unused for this decision rather than removed, since + // deleting a still-configurable global setting is a separate decision from this one). + AppUser maker = commandSource.getMaker(); + if (maker == null) { + throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); + } + if (Objects.equals(appUser.getId(), maker.getId())) { + throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user."); } return commandSource; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java index d0f945f84..cbb1c1bc8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -250,7 +250,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement elsewhere. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); } @@ -306,7 +310,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement elsewhere. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); } 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 053998bf9..9cd21255f 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 @@ -260,7 +260,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -350,7 +354,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } 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 3abffe3ad..5fda9a977 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 @@ -264,7 +264,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -357,7 +361,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java index ca4b88631..8d617f6f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -203,7 +203,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); } @@ -265,7 +269,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); } 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 54d4f96b7..0f94f146a 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 @@ -420,7 +420,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -524,7 +528,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java index 77d7290c9..7eecc2e66 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -172,7 +172,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request, + // it just no longer lets them bypass the hold on their own (matches + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the + // equivalent rule for the classic command pipeline). + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); } @@ -214,7 +219,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request, + // it just no longer lets them bypass the hold on their own (matches + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the + // equivalent rule for the classic command pipeline). + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); } 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 b2ce9c6b8..a135ebead 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 @@ -882,7 +882,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareProductSelfApprovalNotAllowedException(productId); } @@ -968,7 +972,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index d1385c84d..34bf62897 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -156,4 +156,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml new file mode 100644 index 000000000..bbee551d9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml @@ -0,0 +1,73 @@ + + + + + + + + code IN ( + 'UPDATE_CURRENCY', + 'CREATE_FUND', + 'UPDATE_WORKINGDAYS', + 'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE', + 'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK', + 'UPDATE_BRANCHPROFILE', + 'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE', + 'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE', + 'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE', + 'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT', + 'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK', + 'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT', + 'CREATE_USERAGREEMENT', + 'UPDATE_BATCH_BUSINESS_STEP' + ) + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index c4db9dadf..ee634c61c 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -116,7 +116,11 @@ public class CommandSourceServiceTest { } @Test - public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() { + public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() { + // A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission - + // CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This + // replaces the old bypass this test used to assert. NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); CommandSource commandSource = Mockito.mock(CommandSource.class); @@ -125,6 +129,8 @@ public class CommandSourceServiceTest { Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT"); Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT"); Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW"); + Mockito.when(commandSource.getId()).thenReturn(300L); + Mockito.when(commandSource.getResourceId()).thenReturn(400L); Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true); Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); @@ -132,12 +138,12 @@ public class CommandSourceServiceTest { .withCommandId(1L).build(); Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); - org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, - commandSource, superUser, false); + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); - Assertions.assertNotNull(result); - Mockito.verify(commandSource).markAsChecked(superUser); - Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); } @Test From 11a0d502be34f95a58f6694d3f2b2eda94510d95 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 1 Oct 2026 12:03:32 +0530 Subject: [PATCH 26/39] Enforce same-user maker-checker restriction --- .../parts/0012_seed_default_platform_api_client.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml index a75be07a2..b10999153 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml @@ -27,17 +27,17 @@ #/platform-admin/menu-access works out of the box the first time the app is stood up, without anyone needing shell/DB access to mint the first key by hand. It is deliberately the SAME raw value on every deployment/tenant (requested behaviour) rather than a random - one generated per install -- the trade-off, on purpose: this value is visible to anyone who + one generated per install - the trade-off, on purpose: this value is visible to anyone who can read this source file, exactly like a default admin password shipped in a seed script. It is a bootstrap convenience, not a secret. Rotate it for any deployment that is not purely local/dev: insert a new row with a real random key's hash (see ApiClientCredentialService's - hashing) and either deactivate or DELETE this row -- POST /v1/apiclients is intentionally + hashing) and either deactivate or DELETE this row - POST /v1/apiclients is intentionally disabled (see ApiClientAdminApiResource), so rotation is a direct m_api_client write, same as this seed. Raw key (only shown here, since it is fixed and not a secret to rotate away from): cbsx_default_platform_operator_key_2026 - api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase -- matching + api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase - matching ApiClientCredentialService#sha256Hex. --> From 11a206f42ac07166747776bb391e9ba5c5b32ff0 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 1 Oct 2026 12:12:40 +0530 Subject: [PATCH 27/39] feat(deposits): RBI premature closure compliance, simplified to always use the contracted rate Adds a Closure Reason to premature FD closure (Normal, Premature Extension, Conversion to Another TD, Deceased Settlement, Nominee/Legal-Heir), persisted as an RBI audit trail on m_deposit_account_term_and_preclosure. RBI-mandated reasons auto-waive the pre-closure penalty regardless of the maker's own checkbox; the maker can also waive it explicitly for any other reason. Interest rate for a premature closure is always the deposit's own contracted rate -- the same one its regular scheduled postings use throughout its life -- rather than a slab re-lookup against the actual period held. An earlier "actual-period vs contracted" toggle was built, tested, then deliberately removed per explicit request: simpler, and avoids disagreeing with interest the account was already accruing all along. Also fixes a real bug found while testing this: a renewed FD term's premature- closure interest was including the previous term's closing transaction a second time (it's dated the same day the new term starts), understating the first day's balance by the whole prior term's payout. Scoped the interest calculation to transactions strictly after the current term's start date. Known open issue: a renewed/auto-renewed account can still show a materially wrong (sometimes negative) premature-closure interest figure in some cases (seen on account 10, a QUARTERLY-posting auto-renewed FD) -- root cause not yet confirmed; needs further investigation with debug instrumentation before this is considered fully correct for every renewal shape. Co-Authored-By: Claude Sonnet 5 --- .../savings/DepositsApiConstants.java | 8 ++ .../savings/PrematureClosureReasonType.java | 70 ++++++++++++++ ...epositAccountTransactionDataValidator.java | 8 +- .../DepositAccountDomainServiceJpa.java | 15 +++ .../savings/domain/FixedDepositAccount.java | 91 ++++++++++++++++--- ...eMatureCalculationPlatformServiceImpl.java | 31 ++++++- .../tenant/final-changelog-tenant.xml | 1 + .../0588_add_premature_closure_reason.xml | 47 ++++++++++ .../savings/data/FixedDepositAccountData.java | 9 +- .../DepositAccountTermAndPreClosure.java | 15 +++ 10 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/savings/PrematureClosureReasonType.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_add_premature_closure_reason.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index 03c685961..2ca00fc8c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -213,6 +213,14 @@ public final class DepositsApiConstants { public static final String transferDescriptionParamName = "transferDescription"; public static final String toSavingsAccountIdParamName = "toSavingsAccountId"; public static final String reinvestAmountParamName = "reinvestAmount"; + // Lets a maker waive the product's configured pre-closure penalty for one premature closure -- + // see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient + // per-request flag rather than a stored account field. + public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty"; + // Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike + // waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their + // penalty and why. + public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId"; public static final String savingsAccounts = "savingsAccounts"; public static final String maturityInstructionIdParamName = "maturityInstructionId"; public static final String transferToSavingsIdParamName = "transferToSavingsId"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/PrematureClosureReasonType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/PrematureClosureReasonType.java new file mode 100644 index 000000000..4c6708970 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/PrematureClosureReasonType.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; + +import lombok.Getter; + +/** + * Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not + * just the ones that waive the penalty, so the audit trail always shows a reason rather than only + * recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this + * type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a + * free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the + * server regardless of what a client happened to also send for the plain waive-penalty flag. + */ +@Getter +public enum PrematureClosureReasonType { + + INVALID(0, "prematureClosureReasonType.invalid", false), // + NORMAL(100, "prematureClosureReasonType.normal", false), // + PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), // + CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), // + DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), // + NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); // + + private final Integer value; + private final String code; + /** True when this reason, by itself, waives the pre-closure penalty regardless of any manual + * waive-penalty flag also sent -- see the class javadoc. */ + private final boolean autoWaived; + + PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) { + this.value = value; + this.code = code; + this.autoWaived = autoWaived; + } + + public static PrematureClosureReasonType fromInt(final Integer v) { + if (v == null) { + return INVALID; + } + return switch (v) { + case 100 -> NORMAL; + case 200 -> PREMATURE_EXTENSION; + case 300 -> CONVERSION_TO_ANOTHER_TD; + case 400 -> DECEASED_SETTLEMENT; + case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE; + default -> INVALID; + }; + } + + public boolean isInvalid() { + return this.equals(INVALID); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java index 841776e50..2aeaa146d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java @@ -77,10 +77,12 @@ public class DepositAccountTransactionDataValidator { Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName, transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, - bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName)); + bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName, + DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName)); - private static final Set DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>( - Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName)); + private static final Set DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList( + DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, + DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName)); private static final Set DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index acdd50fdc..6e4102a71 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -19,9 +19,11 @@ package org.apache.fineract.portfolio.savings.domain; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName; import java.math.BigDecimal; import java.math.MathContext; @@ -65,6 +67,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; @@ -812,6 +815,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); Long savingsTransactionId = null; + // Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored + // preClosurePenalApplicable config -- this is a one-time override for this closure only, read + // fresh here since the preview and the actual submit are two separate requests. + final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName) + && command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName); + // RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see + // PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual + // closure always matches what the preview showed for the same reason. + final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType + .fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName)); + account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived()); + // post interest account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index d1bbdc50c..788ade745 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; -import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; @@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount { protected InterestRateChartAssembler chartAssembler; @Transient private ConfigurationDomainService configurationDomainService; + /** + * Set once per request, immediately before a premature-closure preview or the real closure, from the + * maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is + * a one-time decision for this specific closure rather than a product/account setting. The product's + * own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so + * every other premature closure of this same product still applies its penalty as normal; this only + * suppresses it for the one request that explicitly asked to waive it. + */ + @Transient + private boolean waivePreClosurePenaltyForThisRequest; protected FixedDepositAccount() { // @@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount { if (this.chart != null) { boolean applyPreMaturePenalty = false; BigDecimal penalInterest = BigDecimal.ZERO; - LocalDate depositCloseDate = calculateMaturityDate(); + // Rate-selection date is always calculateMaturityDate() below -- premature closure prices off + // the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one + // every regular scheduled interest posting this account ever makes (see this method's 2-arg + // overload), so a closure's freshly-recomputed total always matches what the account was + // already accruing all along. No separate "actual period held" slab lookup -- deliberately + // simple, one rate for the account's whole life. + final LocalDate depositCloseDate = calculateMaturityDate(); if (isPreMatureClosure) { - if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) { + if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) { applyPreMaturePenalty = true; penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest(); - final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure - .depositPreClosureDetail().preClosurePenalInterestOnType(); - if (preClosurePenalInterestOnType.isWholeTerm()) { - depositCloseDate = interestCalculatedUpto(); - } else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) { - depositCloseDate = interestPostingUpToDate; - } } } @@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount { boolean isInterestTransfer = false; final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( - transactions); + transactionsForCurrentTermInterestCalculation(transactions)); for (final LocalDateInterval periodInterval : postingPeriodIntervals) { boolean isUserPosting = false; if (postedAsOnTransactionDates.contains(periodInterval.endDate())) { @@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount { final boolean isInterestTransfer = false; final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); final List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( - transactions); + transactionsForCurrentTermInterestCalculation(transactions)); for (final LocalDateInterval periodInterval : postingPeriodIntervals) { final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate()); @@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount { final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType); + // RBI audit trail: record why, not just that, this closure happened -- see + // PrematureClosureReasonType. Absent entirely on a request from before this field existed, + // which resolves to INVALID/null here rather than a misleading "Normal". + final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName); + this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId)); + /* * // withdraw deposit amount before closing the account final Money transactionAmountMoney = * Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw = @@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount { } + /** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview) + * or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the + * same interest calculation this waives a part of. */ + public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) { + this.waivePreClosurePenaltyForThisRequest = waive; + } + public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { @@ -909,10 +931,26 @@ public class FixedDepositAccount extends SavingsAccount { return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null; } + /** + * Interest already posted for the term currently running, NOT the account's whole lifetime. + *

+ * A renewed account keeps every prior term's transactions in {@code this.transactions} -- including + * whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg) + * for a term that has since closed. Summing all of them here double-counted that stale interest: a + * premature closure of the CURRENT term subtracted it from the maturity amount + * ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never + * part of this term's balance to begin with, silently shortchanging the customer by exactly that + * amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its + * deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason + * {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before + * the term restarted belongs to a term that has already been settled. + */ private Money totalInterestPosted() { + final LocalDate currentTermStartDate = depositStartDate(); Money interestPostedToDate = Money.zero(this.currency); for (final SavingsAccountTransaction transaction : this.transactions) { - if (transaction.isInterestPostingAndNotReversed()) { + if (transaction.isInterestPostingAndNotReversed() + && !DateUtils.isBefore(transaction.getTransactionDate(), currentTermStartDate)) { interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency)); } } @@ -960,6 +998,33 @@ public class FixedDepositAccount extends SavingsAccount { return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate()); } + /** + * A renewed term's opening balance is seeded straight to the term's own principal (see + * calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF + * depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing + * transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed + * and the new one opened in the same stroke), so without this filter it still falls inside the new + * term's posting-period walk and gets applied a SECOND time on top of the already-net seed -- + * understating the very first day's balance by the old term's entire payout, which silently corrupts + * that day's interest for as long as the term keeps re-including it (every premature-closure preview, + * and any later maturity recalculation). Scoping to transactions strictly after the term start avoids + * the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway. + */ + private List transactionsForCurrentTermInterestCalculation( + final List transactions) { + if (!isRenewedTerm()) { + return transactions; + } + final LocalDate currentTermStartDate = depositStartDate(); + final List scoped = new ArrayList<>(); + for (final SavingsAccountTransaction transaction : transactions) { + if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) { + scoped.add(transaction); + } + } + return scoped; + } + private LocalDate interestCalculatedUpto() { LocalDate uptoMaturityDate = calculateMaturityDate(); if (uptoMaturityDate != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java index eacefbdec..822d6bcb8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java @@ -19,8 +19,11 @@ package org.apache.fineract.portfolio.savings.service; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName; import com.google.gson.JsonElement; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.Collection; import lombok.RequiredArgsConstructor; @@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.data.DepositAccountData; import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator; import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData; @@ -77,13 +81,32 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De // calculate interest before one day of closure date final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1); final boolean isPreMatureClosure = true; + // Preview-time only -- the real closure re-reads this same box from its own request when it + // actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a + // maker previewing with the box checked and then submitting without it (or vice versa) gets + // exactly what they last confirmed, not what an earlier preview computed. + final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element) + && this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element); + // RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased + // settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox + // above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so + // the preview a maker sees always matches what the actual closure will compute. + final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName, + element); + final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId); + final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived(); if (depositAccountType.isFixedDeposit()) { final FixedDepositAccount fd = (FixedDepositAccount) account; - accountData = FixedDepositAccountData.preClosureDetails( - account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth), - onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas); + fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty); + final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + // calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's + // own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview -- + // the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here + // rather than re-deriving it, to show the maker exactly what applied. + accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions, + paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate()); } else if (depositAccountType.isRecurringDeposit()) { final RecurringDepositAccount rd = (RecurringDepositAccount) account; accountData = RecurringDepositAccountData.preClosureDetails( diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index d1385c84d..9b2f6e419 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -156,4 +156,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_add_premature_closure_reason.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_add_premature_closure_reason.xml new file mode 100644 index 000000000..e7e35d6b0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_add_premature_closure_reason.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index edb6c2d02..2758c91da 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -674,7 +674,7 @@ public final class FixedDepositAccountData extends DepositAccountData { public static FixedDepositAccountData preClosureDetails(final Long accountId, BigDecimal maturityAmount, final Collection onAccountClosureOptions, final Collection paymentTypeOptions, - final Collection savingsAccountDatas) { + final Collection savingsAccountDatas, final BigDecimal effectiveInterestRate) { final Long groupId = null; final String groupName = null; @@ -689,7 +689,12 @@ public final class FixedDepositAccountData extends DepositAccountData { final SavingsAccountStatusEnumData status = null; final SavingsAccountApplicationTimelineData timeline = null; final CurrencyData currency = null; - final BigDecimal nominalAnnualInterestRate = null; + // Not the account's stored contracted rate -- the rate actually resolved for THIS premature + // closure preview (see FixedDepositAccount#getEffectiveInterestRateAsFraction, read back + // straight after the calculation by the caller), so a maker can see which of the RBI-default + // actual-period rate, a bank's "use contracted rate" override, and any penalty deduction ended + // up applying, rather than only the final rupee amount. + final BigDecimal nominalAnnualInterestRate = effectiveInterestRate; final EnumOptionData interestPeriodType = null; final EnumOptionData interestPostingPeriodType = null; final EnumOptionData interestCalculationType = null; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java index 0490f3d09..136c60a45 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java @@ -45,6 +45,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; @@ -118,6 +119,20 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom Date: Thu, 1 Oct 2026 12:12:51 +0530 Subject: [PATCH 28/39] fix(accounting): post GL entries for a negative (clawback) interest transaction A premature FD closure that reprices interest downward (e.g. a pre-closure penalty) posts the difference as a negative interest-posting transaction -- a clawback of interest already recognised in earlier periods. Both the cash- and accrual-based accounting processors only posted journal entries when the transaction amount was greater than zero, so a clawback updated the FD subledger but silently posted nothing to the General Ledger, leaving the two out of sync. Treats a negative amount as a mirror-image reversal: positive magnitude, debit/credit legs swapped -- the same mechanism already used for isReversal. Co-Authored-By: Claude Sonnet 5 --- ...ualBasedAccountingProcessorForSavings.java | 21 +++++++++++++++---- ...ashBasedAccountingProcessorForSavings.java | 19 +++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java index 748daf5d0..5092daed6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java @@ -173,12 +173,25 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc } else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) { - // Post journal entry if earned interest amount is greater than - // zero - if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { + // A premature FD closure that reprices interest downward (see + // FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE + // interest-posting transaction -- a clawback of interest already recognised in earlier + // periods, not "zero interest, nothing to post". Skipping it here (the original + // "amount > 0" guard) left the FD subledger showing the clawback while the GL never + // recorded it, silently going out of sync. Treating a negative amount as a mirror-image + // reversal -- positive magnitude, debit/credit legs swapped, same mechanism + // createCashBasedJournalEntriesAndReversalsForSavings already uses for isReversal -- keeps + // the two in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on + // the cash-based path. + final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO); + if (amountSign != 0) { + final boolean isClawback = amountSign < 0; + final BigDecimal postingAmount = isClawback ? amount.abs() : amount; + final boolean effectiveIsReversal = isReversal ^ isClawback; this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, postingAmount, + effectiveIsReversal); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java index d6a6034e4..1ca1ad249 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java @@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess } else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) { - // Post journal entry if earned interest amount is greater than - // zero - if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { + // A premature FD closure that reprices interest downward (see + // FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE + // interest-posting transaction -- a clawback of interest already recognised in earlier + // periods, not "zero interest, nothing to post". Skipping it here (the original + // "amount > 0" guard) left the FD subledger showing the clawback while the GL never + // recorded it, silently going out of sync. Treating a negative amount as a mirror-image + // reversal -- positive magnitude, debit/credit legs swapped, same mechanism + // createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two + // in sync instead. + final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO); + if (amountSign != 0) { + final boolean isClawback = amountSign < 0; + final BigDecimal postingAmount = isClawback ? amount.abs() : amount; + final boolean effectiveIsReversal = isReversal ^ isClawback; // Routed through the payable and paid accounts the product names, so the society // can read interest declared but not yet credited as its own balance. Collapses // to the plain Dr Interest On Savings / Cr Savings Control pair for a product // that names neither. this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId, - savingsId, transactionId, transactionDate, amount, isReversal); + savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal); } } From 0d6066e0b64cd94a21acbf1760dd4c86fbd3c04b Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 1 Oct 2026 12:13:02 +0530 Subject: [PATCH 29/39] fix(useradmin): strip password placeholder from a pending-edit replay payload A pending user edit's stored payload keeps a "[PROTECTED]" placeholder for the password rather than the real value. The actual new password is applied separately via request.getPasswordHash(), so the placeholder must not reach AppUser.update() or it tries to re-encode the literal placeholder string. Co-Authored-By: Claude Sonnet 5 --- .../service/UserPendingWritePlatformServiceImpl.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java index 3806b6b24..16d140cb1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java @@ -321,6 +321,14 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat .orElseThrow(() -> new UserNotFoundException(request.getUserId())); final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson()); + if (parsedElement.isJsonObject()) { + // the stored payload keeps a "[PROTECTED]" placeholder for the password rather than + // the real value; the actual new password is applied below via request.getPasswordHash(), + // so these keys must not reach AppUser.update() or it will try to re-encode the placeholder + final JsonObject payloadObject = parsedElement.getAsJsonObject(); + payloadObject.remove("password"); + payloadObject.remove("repeatPassword"); + } final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper); final Map changes = userToUpdate.update(replay, null, null); From 56d98bac8d4868576ff0cf726510317c7237fa3d Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 1 Oct 2026 13:07:58 +0530 Subject: [PATCH 30/39] feat(rbi-reserve,savings): add Form1/Form9 returns, interest posting report and branch-day enhancements --- .../infrastructure/jobs/service/JobName.java | 1 + .../validation/GLAccountTransactionGuard.java | 45 + .../service/AccountingProcessorHelper.java | 97 +- ...ualBasedAccountingProcessorForSavings.java | 17 +- .../api/BranchDayRegisterApiResource.java | 54 + ...lerkShareWithdrawalRequestApiResource.java | 42 + .../api/RbiFormAFilingApiResource.java | 59 +- .../api/RbiReserveConfigApiResource.java | 41 +- .../api/RbiReserveReturnApiResource.java | 1510 ++++++++++++++++- .../service/RbiFormAAutoSaveService.java | 72 +- .../SavingsDailyInterestOrchestrator.java | 151 +- .../SavingsDailyInterestService.java | 28 +- .../SavingsInterestPostingReportService.java | 578 +++++++ .../SavingsPooledInterestPostingService.java | 456 ++--- ...vingsInterestPostingReportApiResource.java | 191 +++ .../SavingsInterestPostingReportConfig.java | 53 + .../SavingsInterestPostingReportTasklet.java | 45 + ...SavingsAccountReadPlatformServiceImpl.java | 8 +- .../src/main/resources/application.properties | 4 +- .../tenant/final-changelog-tenant.xml | 6 + .../parts/0585_form1_statutory_heads.xml | 240 +++ .../tenant/parts/0586_form9_return_heads.xml | 908 ++++++++++ .../parts/0587_form9_default_mappings.xml | 212 +++ .../0588_form9_map_dividend_round_off.xml | 58 + .../0589_savings_interest_posting_report.xml | 204 +++ ...ings_interest_posting_report_by_branch.xml | 80 + 26 files changed, 4793 insertions(+), 367 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_form1_statutory_heads.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index ba41bb64c..93e097cc6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -43,6 +43,7 @@ public enum JobName { UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), // SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // + SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), // diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java index 08b7cea5a..0c324de1e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java @@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard { "glAccountId", glAccountId); } } + + /** + * Refuses to draw more out of a holding account than it holds. + * + *

+ * A suspense or sundry account exists to hold money on its way somewhere else, so what is in it + * is the whole of what can come out. Drawing more leaves it with a negative balance, and a + * liability with a negative balance is not a small number - it says the bank owes less than + * nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw, + * and the figure is small enough to sit unnoticed inside a head that totals positive. + * + *

+ * Checked here rather than at each call site because the accounts this protects are reached by + * several features posting directly through JDBC, and a rule enforced in one of them is a rule + * the next one added will not have. + * + * @param glAccountId the account being drawn on. + * @param amount how much the caller wants to take out. + * @param what the account's role in the caller's own words, for the message a clerk reads. + */ + public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId, + final java.math.BigDecimal amount, final String what) { + if (glAccountId == null || amount == null || amount.signum() <= 0) { + return; + } + // Credits less debits: these are liability-natured holding accounts, so the balance is what + // is still owed onward. Reversed entries and the entries they reverse both drop out - a + // reversal pair nets to nothing, and counting either side alone moves the balance by the + // whole amount. + final java.math.BigDecimal held = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0) + FROM acc_gl_journal_entry + WHERE account_id = ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """, java.math.BigDecimal.class, glAccountId); + final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held; + if (balance.compareTo(amount) < 0) { + throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance", + what + " holds " + balance + " but " + amount + " was asked of it. Drawing the " + + "difference would leave the account with a negative balance, which a " + + "holding account cannot have.", + "glAccountId", glAccountId, amount); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index b5c375a61..1ee9fe013 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -42,6 +42,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; +import org.apache.fineract.accounting.glaccount.domain.GLAccountType; import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; @@ -666,30 +667,56 @@ public class AccountingProcessorHelper { final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) .orElse(payableAccount); - // Stage 1: the expense is recognised against whatever the liability sits in right now -- the - // named Payable account if the product has one, or straight against Savings Control if it - // doesn't. This was missing entirely: every product, configured or not, posted zero journal - // entries for interest until this leg existed, since a product with no Payable/Paid names - // configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else - // to post in their place. - postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount, + // A product that names no payable falls back to Savings Control above. The chain below would + // then open with Dr Savings Control / Cr Interest On Savings and close with its exact + // reverse, the two cancelling: the expense never charged and the member never credited. Such + // a product has no liability to draw down, so it keeps the single pair it always posted. + if (payableAccount.getId().equals(controlAccount.getId())) { + postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate, + amount, isReversal); + return; + } + + // The interest is charged to expense and raised as a liability first, for whatever part of + // it no accrual has already covered. + // + // The chain below draws the payable DOWN, so it assumes an accrual already credited it for + // the period. Where none ran - a product posting on cash accounting, or a period the accrual + // job never covered - the payable went negative by the interest and the expense netted to + // nothing across the next two legs, leaving the charge off the P&L entirely. A liability + // account cannot hold a negative balance: the bank does not owe less than nothing. + // + // So the shortfall is raised here rather than assumed away. Where the accrual DID run, the + // payable already carries the interest and this leg writes nothing - the balance below is + // enough, and raising it again would double the liability. + // + // Only a liability payable is topped up. A bank that maps an expense GL as the payable books + // the cost there directly: payableBalance reads credit minus debit, so an expense's normal + // debit balance would come back negative and every posting would raise a shortfall larger + // than the interest itself. + final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType()); + final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode); + final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO); + if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) { + // Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run. + postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, + shortfall, false); + } + + // Out of the payable, into the interest-paid account the product names. + postSavingsLeg(office, currencyCode, payableAccount, interestOnSavingsAccount, savingsId, transactionId, transactionDate, amount, isReversal); - // The interest expense is not raised again here. It was just raised above, and this posting - // only settles that recognised debt onward to where it is actually paid out from -- raising - // the expense a second time would put the same interest on the P&L twice. - // - // Stage 2: the liability moves from the payable to the account it is paid out of. Where the - // product names no paid account both resolve to the payable and this says nothing, so it is - // skipped and the payout comes straight out of the payable. - final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); - if (!payableIsPaid) { - postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + // A separate paid account, where the product names one, sits between interest-paid and the + // member; otherwise the payout leaves straight from interest-paid. + final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId()); + if (hasSeparatePaidAccount) { + postSavingsLeg(office, currencyCode, interestOnSavingsAccount, paidAccount, savingsId, transactionId, transactionDate, amount, isReversal); } - // Stage 3: into the member's own balance. - final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; + // Into the member's own balance. + final GLAccount payoutFromAccount = hasSeparatePaidAccount ? paidAccount : interestOnSavingsAccount; postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); } @@ -733,6 +760,38 @@ public class AccountingProcessorHelper { * * @return the account, or null where the product does not configure this name */ + /** + * What the interest payable account currently holds for this office, on its own liability side. + * + *

+ * Read so an interest posting can tell an accrual that already ran from one that did not. The + * posting chain draws the payable down; where nothing had raised it, the account went negative + * and the expense netted to nothing, so the charge never reached the P&L at all. The caller + * raises only the shortfall, which is nil when the accrual did run. + * + *

+ * Credits less debits, because a payable is a liability: the balance is what the bank still + * owes. Floored at zero by the caller rather than here - a negative balance is a real state to + * report, and the caller's arithmetic needs the true figure, not a clamped one. + */ + private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) { + if (payableAccount == null || payableAccount.getId() == null) { + return BigDecimal.ZERO; + } + // Reversed entries and the entries they reverse are both excluded: a reversal pair nets to + // nothing, and counting either side alone would move the balance by the whole amount. + final BigDecimal balance = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0) + FROM acc_gl_journal_entry + WHERE account_id = ? + AND office_id = ? + AND currency_code = ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """, BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode); + return balance == null ? BigDecimal.ZERO : balance; + } + private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { if (savingsProductId == null) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java index 5092daed6..82d124e58 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java @@ -180,18 +180,21 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc // "amount > 0" guard) left the FD subledger showing the clawback while the GL never // recorded it, silently going out of sync. Treating a negative amount as a mirror-image // reversal -- positive magnitude, debit/credit legs swapped, same mechanism - // createCashBasedJournalEntriesAndReversalsForSavings already uses for isReversal -- keeps - // the two in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on - // the cash-based path. + // createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two + // in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the + // cash-based path. final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO); if (amountSign != 0) { final boolean isClawback = amountSign < 0; final BigDecimal postingAmount = isClawback ? amount.abs() : amount; final boolean effectiveIsReversal = isReversal ^ isClawback; - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, postingAmount, - effectiveIsReversal); + // Routed through the same helper the cash processor uses, so a posting states + // the whole chain the product is configured for -- out of the payable, through + // the interest-paid account, into the member's balance -- rather than the single + // payable-to-control pair that used to be here, which skipped interest-paid + // entirely and left the expense off the P&L. + this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId, + savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 31ac82030..71920ecff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -73,6 +75,8 @@ public class BranchDayRegisterApiResource { private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; private final VaultPositionService vaultPositionService; + private final SavingsInterestPostingReportService interestPostingReportService; + private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; private final Gson gson = new Gson(); // ── GET /v1/branchday ── the register itself ────────────────────────────── @@ -589,6 +593,34 @@ public class BranchDayRegisterApiResource { "Business day " + businessDate + " is already closed."); } + // ── This branch's savings interest reports must be approved before it closes ── + // Not overridable by a force-close reason: the branch posts its own customers' interest when + // it closes, and a report nobody approved has nothing to post, so they would go unpaid. + final List interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate); + if (!interestPending.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending", + "Cannot close the day — this branch's savings interest posting report is not approved for: " + + String.join(", ", interestPending) + + ". Approve it under Reports → Interest Posting Reports, then close the day."); + } + + // ── The head office closes last ── + // Every branch that opened the day closes first and posts its own interest; a branch that + // never opened is not waited for. + final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId); + if (isHeadOffice) { + final List stillOpen = jdbcTemplate.queryForList(""" + SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id + WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ? + ORDER BY o.name + """, String.class, Date.valueOf(businessDate), officeId); + if (!stillOpen.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open", + "Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen) + + ". The head office closes last."); + } + } + // ── Ensure every cashier holding cash has been reconciled AND settled ── // Find all cashiers under tellers belonging to this office, check if they have a cash position, // and if so, verify reconciliation and settlement records exist for the business date. @@ -687,6 +719,28 @@ public class BranchDayRegisterApiResource { final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash"); final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null; + // ── The branch posts its own approved savings interest as it closes ── + // The head office also posts what it approved for branches that did not open today. Entries + // are raised in the customers' own branch either way. + // + // Posted here, before the close is written, and synchronously, so the outcome is never + // silent: each report posts in a transaction of its own and commits, and if any fails the + // close is refused with the reason — the day stays open, the report stays APPROVED, and + // closing again once the cause is fixed posts it. + final List toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate)); + if (isHeadOffice) { + toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId)); + } + if (!toPost.isEmpty()) { + log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size()); + final List interestFailures = savingsDailyInterestOrchestrator.postReports(toPost); + if (!interestFailures.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed", + "Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures) + + ". The report is still approved; fix the cause and close the day again."); + } + } + // "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and // this write are two separate statements, so two managers pressing Close at once (or one // double-click) both got past the check. The second UPDATE then overwrote the first's diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index dc9690edf..da1a79dc7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -709,6 +709,21 @@ public class ClerkShareWithdrawalRequestApiResource { + "applied to it, so these shares have not actually been paid for and cannot be redeemed."); } + // The Share Sundry account the redemption draws on must actually hold the money. + // + // collectionApplied above says A collection was applied to THIS account, not that the + // proceeds are still sitting in Sundry - the same holding account serves every share + // account, and an earlier redemption may already have drawn it down. Approving past that + // takes Sundry negative, which is a liability owing less than nothing, and the overdraw + // lands inside a balance-sheet head that totals positive, where it does not look wrong. + final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares); + if (redemptionValue != null) { + final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class, + shareAccountId); + GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue, + "The Share Sundry (Suspense) account"); + } + final JsonObject payload = new JsonObject(); payload.addProperty("locale", "en"); payload.addProperty("dateFormat", "dd MMMM yyyy"); @@ -958,6 +973,33 @@ public class ClerkShareWithdrawalRequestApiResource { * text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a * reset id sequence in a dev environment can violate. */ + /** + * What a redemption of this many shares will draw out of Share Sundry. + * + *

+ * Priced at the unit price the member's own shares were issued at, which is the price the + * redemption itself uses. The product's nominal price is not consulted: a member who subscribed + * at an earlier price redeems at theirs, and pricing the guard differently from the posting + * would either block a redemption that fits or let one through that does not. + * + * @return the value, or null where no price can be established - the guard then stands aside + * rather than blocking on a figure it had to invent. + */ + private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) { + if (shareAccountId == null || requestedShares == null || requestedShares <= 0) { + return null; + } + // The purchase that issued the holding - type 500. Its unit price is what the member paid. + final BigDecimal unitPrice = jdbcTemplate + .queryForList(""" + SELECT unit_price FROM m_share_account_transaction + WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL + ORDER BY id DESC LIMIT 1 + """, BigDecimal.class, shareAccountId) + .stream().findFirst().orElse(null); + return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares)); + } + private Long redeemDebitGlOf(final Long shareTransactionId) { if (shareTransactionId == null) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java index fdb524c9a..184a4f990 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java @@ -166,12 +166,23 @@ public class RbiFormAFilingApiResource { "The generated return must be supplied with the save."); } - final LocalDate periodStart = localDate(report, "fortnightStart"); - final LocalDate periodEnd = localDate(report, "fortnightEnd"); - final BigDecimal ndtl = decimal(report, "ndtlAmount"); - final BigDecimal required = decimal(report, "requiredAmount"); - final BigDecimal maintained = decimal(report, "maintainedAmount"); - final BigDecimal rate = decimal(report, "ratePercentage"); + // The statutory form states its figures per column inside Part B rather than once at the + // top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be + // read out of it. Without this a filed statutory return listed as a row of dashes, and the + // list could not be scanned for the one return somebody was looking for. + final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull() + && "FORM_1_STATUTORY".equals(report.get("formCode").getAsString()); + + // The statutory form's period runs between its two fortnight closes, which is what the + // saved list prints as the row's period. + final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart"); + final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd"); + // The current fortnight's figures are the second column - the close the form is made up to. + final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount"); + final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount"); + final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained") + : decimal(report, "maintainedAmount"); + final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : decimal(report, "ratePercentage"); BigDecimal average = null; if (report.has("maintenance") && report.get("maintenance").isJsonObject()) { average = decimal(report.getAsJsonObject("maintenance"), "average"); @@ -302,4 +313,40 @@ public class RbiFormAFilingApiResource { final String value = object.get(field).getAsString(); return value == null || value.isBlank() ? null : LocalDate.parse(value); } + + /** + * One of a statutory part's per-column figures, taken at the column the form is made up to. + * + * The form carries three columns and the saved list has one cell per figure, so it takes the + * second - the close the return reports. Index 1 rather than "the last": the third column is + * the base the ratios are charged on, not a later position, and listing it would show a + * requirement against liabilities from a fortnight nobody filed. + */ + private static BigDecimal partFigure(final JsonObject report, final String part, final String field) { + if (!report.has(part) || !report.get(part).isJsonObject()) { + return null; + } + final JsonObject partObject = report.getAsJsonObject(part); + if (!partObject.has(field) || !partObject.get(field).isJsonArray()) { + return null; + } + final var values = partObject.getAsJsonArray(field); + if (values.size() < 2 || values.get(1).isJsonNull()) { + return null; + } + return values.get(1).getAsBigDecimal(); + } + + /** One of the statutory form's column dates, for the period a saved row reports. */ + private static LocalDate columnDate(final JsonObject report, final int index) { + if (!report.has("columns") || !report.get("columns").isJsonArray()) { + return null; + } + final var columns = report.getAsJsonArray("columns"); + if (columns.size() <= index || columns.get(index).isJsonNull()) { + return null; + } + final String value = columns.get(index).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java index 9b07890e5..3b40943d3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -199,6 +199,7 @@ public class RbiReserveConfigApiResource { ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)"); ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities"); ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings"); + ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)"); for (final String role : ROLE_DESCRIPTIONS.keySet()) { if (role.startsWith("OSS1_")) { @@ -227,9 +228,15 @@ public class RbiReserveConfigApiResource { */ private static final List RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND"); - /** The four treatments a head can be given, each naming a branch the returns already have. */ + /** + * The treatments a head can be given, each naming a branch the returns already have. + * + * ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive + * at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be + * subtracted from the liabilities of a return that has no such base. + */ private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET", - "MEMO"); + "ASSET", "MEMO"); private static final Map USAGE_DESCRIPTIONS = new LinkedHashMap<>(); static { @@ -243,6 +250,27 @@ public class RbiReserveConfigApiResource { USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total"); } + /** + * How a head is consumed, in the terms of the return it belongs to. + * + * LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the + * head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no + * base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL + * wording would tell a Form IX filer their head feeds a calculation that does not exist on + * their return. + */ + private static String usedBy(final String group, final String usage) { + if ("FORM9".equals(group)) { + return switch (usage == null ? "" : usage) { + case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities"; + case "ASSET" -> "Form IX — an asset; enters Total Assets"; + case "MEMO" -> "Form IX — stated on the form but entering no total"; + default -> usage; + }; + } + return USAGE_DESCRIPTIONS.getOrDefault(usage, usage); + } + /** How each built-in role is treated, so the screen can describe built-ins and additions alike. */ private static final Map BUILT_IN_USAGE = new LinkedHashMap<>(); static { @@ -390,11 +418,14 @@ public class RbiReserveConfigApiResource { o.put("label", row.get("label")); o.put("group", group); o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group)); - o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage)); + o.put("usedBy", usedBy(group, usage)); o.put("builtIn", false); o.put("usage", usage); - // A liability head carries the demand/time split; nothing else does. - o.put("classified", "LIABILITY".equals(usage)); + // A liability head carries the demand/time split; nothing else does. Not on Form IX, + // where the demand and time heads are separate lines of the form itself - asking for a + // class on top of that would let a head declared under 5(a) Fixed Deposits be marked + // DEMAND, which the form has no way to report. + o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group)); o.put("roleId", row.get("id")); o.put("accounts", byKey.getOrDefault(key, new ArrayList<>())); out.add(o); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java index ad11ff8f9..b8e2c98a7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -30,10 +30,13 @@ import java.time.DayOfWeek; import java.time.LocalDate; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.springframework.jdbc.core.JdbcTemplate; @@ -138,15 +141,23 @@ public class RbiReserveReturnApiResource { @GET @Path("form1") public String form1(@QueryParam("reportingDate") final String reportingDateParam, - @QueryParam("officeId") final Long officeId) { + @QueryParam("baseDate") final String baseDateParam, @QueryParam("officeId") final Long officeId) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); - final LocalDate reportingDate = parseOrBusinessDate(reportingDateParam); + final LocalDate reportingDate = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS); // A reporting Friday that falls on a holiday has no close of business to strike a balance // at, so the position is taken at the preceding working day instead. final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate); - final LocalDate dtlDate = precedingWorkingDay(nominalDtlDate); + // The base date can be given outright. The 45-day lag lands where it lands, and a bank + // reading an earlier fortnight - or correcting a filing - needs to name the date the base + // is struck at rather than work backwards from it. Given explicitly it is used as given; + // otherwise it is derived from the lag as before. + final boolean baseGiven = baseDateParam != null && !baseDateParam.isBlank(); + final LocalDate dtlDate = baseGiven + ? requirePastOrPresent(LocalDate.parse(baseDateParam), "The base date") + : precedingWorkingDay(nominalDtlDate); final Map response = new LinkedHashMap<>(); response.put("formCode", "FORM_1"); @@ -155,8 +166,10 @@ public class RbiReserveReturnApiResource { response.put("reportingDate", reportingDate.toString()); response.putAll(fortnightOf(reportingDate)); - // The lag basis, spelled out so a reviewer can check it rather than trust it. - final boolean shifted = !dtlDate.equals(nominalDtlDate); + // The lag basis, spelled out so a reviewer can check it rather than trust it. A base date + // the filer chose is said to be theirs: the note must never imply the lag produced a date + // somebody typed. + final boolean shifted = !baseGiven && !dtlDate.equals(nominalDtlDate); final Map basis = new LinkedHashMap<>(); basis.put("lagDays", DTL_LAG_DAYS); basis.put("rawLagDate", rawLagDate.toString()); @@ -164,13 +177,18 @@ public class RbiReserveReturnApiResource { basis.put("dtlAsOnDate", dtlDate.toString()); basis.put("holdingsAsOnDate", reportingDate.toString()); basis.put("shiftedForHoliday", shifted); - basis.put("note", "DTL is taken as at the reporting Friday on or before " + rawLagDate + " (" - + DTL_LAG_DAYS + " days before the reporting date)" - + (shifted - ? "; that Friday (" + nominalDtlDate + ") was a holiday, so the position is taken at the " - + "preceding working day " + dtlDate - : "") - + "; balances maintained are taken as at " + reportingDate + "."); + basis.put("baseDateChosen", baseGiven); + basis.put("note", baseGiven + ? "DTL is taken as at " + dtlDate + ", the base date entered for this return; balances maintained " + + "are taken as at " + reportingDate + "." + : "DTL is taken as at the reporting Friday on or before " + rawLagDate + " (" + DTL_LAG_DAYS + + " days before the reporting date)" + + (shifted + ? "; that Friday (" + nominalDtlDate + + ") was a holiday, so the position is taken at the preceding working day " + + dtlDate + : "") + + "; balances maintained are taken as at " + reportingDate + "."); response.put("basis", basis); final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId); @@ -207,25 +225,32 @@ public class RbiReserveReturnApiResource { final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO); final List> lines = new ArrayList<>(); - lines.add(line("Demand and Time Liabilities (DTL)", dtl, dtlDate)); + lines.add(withComparison(line("Demand and Time Liabilities (DTL)", dtl, dtlDate), + "DEMAND_TIME_LIABILITIES", dtlDate, reportingDate, officeId)); for (final CustomHead head : heads) { if (USAGE_LIABILITY.equals(head.usage)) { - lines.add(line(head.label, head.figure, dtlDate)); + lines.add(withComparison(line(head.label, head.figure, dtlDate), head.figure.configKey, dtlDate, + reportingDate, officeId)); } } // The two heads the liability total is built from, each stated on the face of the return as // RBI reports them rather than left implicit inside the total above. lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate)); lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate)); - lines.add(line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate)); + lines.add(withComparison( + line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate), + "BALANCE_WITH_OTHER_BANKS", dtlDate, reportingDate, officeId)); for (final CustomHead head : heads) { if (USAGE_ASSET_NET.equals(head.usage)) { - lines.add(line("Less: " + head.label, head.figure, dtlDate)); + lines.add(withComparison(line("Less: " + head.label, head.figure, dtlDate), head.figure.configKey, + dtlDate, reportingDate, officeId)); } } lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate)); - lines.add(line("Cash in hand — counts toward CRR maintained", cash, reportingDate)); - lines.add(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate)); + lines.add(withComparison(line("Cash in hand — counts toward CRR maintained", cash, reportingDate), + "CASH_IN_HAND", dtlDate, reportingDate, officeId)); + lines.add(withComparison(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate), + "BALANCE_WITH_OTHER_BANKS", dtlDate, reportingDate, officeId)); for (final CustomHead head : heads) { // A head belonging to the Memorandum or an Annexure prints under that section's own // heading below, not here: listing it in both places would show one figure twice and @@ -234,11 +259,16 @@ public class RbiReserveReturnApiResource { continue; } if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { - lines.add(line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure, - reportingDate)); + lines.add(withComparison( + line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure, + reportingDate), + head.figure.configKey, dtlDate, reportingDate, officeId)); } } response.put("lines", lines); + // Named so the screen can head its two columns without re-deriving either date. + response.put("baseDate", dtlDate.toString()); + response.put("currentDate", reportingDate.toString()); final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured); response.put("computable", computable); @@ -355,8 +385,15 @@ public class RbiReserveReturnApiResource { @QueryParam("officeId") final Long officeId) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); - final LocalDate requested = parseOrBusinessDate(reportingDateParam); - final LocalDate monthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); + final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + // Run inside the current month, the month end has not arrived. Reading balances at it + // returned today's figures under the month-end heading, so a month still running reported + // as a closed one - with nothing on the return to say the month was not over. + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate monthEnd = minimum(nominalMonthEnd, today); + final boolean monthComplete = !nominalMonthEnd.isAfter(today); final Map response = new LinkedHashMap<>(); response.put("formCode", "FORM_9"); @@ -364,13 +401,22 @@ public class RbiReserveReturnApiResource { response.put("frequency", "Monthly"); response.put("requestedDate", requested.toString()); response.put("reportingDate", monthEnd.toString()); + // Stated so a return read before the month closes cannot be mistaken for the filed one. + response.put("monthEnd", nominalMonthEnd.toString()); + response.put("monthComplete", monthComplete); response.put("monthStart", requested.withDayOfMonth(1).toString()); final Map basis = new LinkedHashMap<>(); basis.put("dtlAsOnDate", monthEnd.toString()); basis.put("holdingsAsOnDate", monthEnd.toString()); - basis.put("note", "SLR is reported on the month-end position: both the DTL base and the " - + "SLR-eligible investments are taken as at " + monthEnd + "."); + basis.put("note", monthComplete + ? "SLR is reported on the month-end position: both the DTL base and the " + + "SLR-eligible investments are taken as at " + monthEnd + "." + // Said plainly rather than left to the dates: a provisional return that reads like a + // filed one is the whole risk here. + : "The month ending " + nominalMonthEnd + " has not closed. Both the DTL base and " + + "the SLR-eligible investments are taken as at " + monthEnd + + ", the position so far, and will move before the month ends."); response.put("basis", basis); final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId); @@ -540,6 +586,10 @@ public class RbiReserveReturnApiResource { figure.configured = true; for (final Map account : accounts) { + // The mapping row, not the account. One account can be pinned to a role twice - a + // savings balance split into its demand and time slices - and the comparison below + // matches rows across the two dates by this, so the two slices stay apart. + final Long mappingId = ((Number) account.get("mapping_id")).longValue(); final Long accountId = ((Number) account.get("id")).longValue(); final int classification = ((Number) account.get("classification_enum")).intValue(); final String glCode = (String) account.get("gl_code"); @@ -559,6 +609,7 @@ public class RbiReserveReturnApiResource { if (disabled || header) { figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)")); final Map skipped = new LinkedHashMap<>(); + skipped.put("mappingId", mappingId); skipped.put("glAccountId", accountId); skipped.put("glCode", glCode); skipped.put("name", account.get("name")); @@ -607,6 +658,7 @@ public class RbiReserveReturnApiResource { } final Map detail = new LinkedHashMap<>(); + detail.put("mappingId", mappingId); detail.put("glAccountId", accountId); detail.put("glCode", glCode); detail.put("name", account.get("name")); @@ -890,6 +942,89 @@ public class RbiReserveReturnApiResource { return out; } + /** + * Fills in a line's figure at the OTHER of the return's two dates, so every row can be read + * across both columns rather than only at the one date its own figure belongs to. + * + *

+ * The return is built from figures struck at two different dates - the base at its lagged date, + * the holdings at the reporting date - and a reader comparing them has no way to tell a real + * movement from the two dates simply being different. Reading each line at both dates makes the + * movement visible: the DTL line then shows what the liabilities were on the base date AND what + * they are now, and the cash line the same. + * + *

+ * The line's own {@code amount} is left exactly as it was. That is the figure the return is + * computed from, and nothing here may move it. + */ + private Map withComparison(final Map row, final String configKey, + final LocalDate baseDate, final LocalDate reportingDate, final Long officeId) { + if (configKey == null) { + return row; + } + final String ownDate = (String) row.get("asOnDate"); + final boolean ownIsBase = baseDate.toString().equals(ownDate); + final LocalDate otherDate = ownIsBase ? reportingDate : baseDate; + + final RoleFigure other = figureFor(configKey, otherDate, officeId); + row.put("comparisonDate", otherDate.toString()); + row.put("comparisonAmount", other.configured ? other.amount : null); + + // Stated as base-then-current whichever date the line's own figure belongs to, so a column + // means the same thing on every row of the return. + final BigDecimal own = (BigDecimal) row.get("amount"); + row.put("baseAmount", ownIsBase ? own : other.amount); + row.put("currentAmount", ownIsBase ? other.amount : own); + if (own != null) { + final BigDecimal from = ownIsBase ? own : other.amount; + final BigDecimal to = ownIsBase ? other.amount : own; + row.put("movement", to.subtract(from)); + } + + // The accounts behind the line get the same treatment as the line itself. Without this the + // breakdown states each account at one date and leaves the other column blank, so a reader + // who can see a head has moved cannot see WHICH account moved - which is the one question + // the breakdown exists to answer. + // + // Matched on the mapping row, so a savings balance split into a demand slice and a time + // slice keeps the two apart instead of giving both the same figure. + final Map> otherAccounts = new LinkedHashMap<>(); + for (final Map account : other.accounts) { + final Object key = account.get("mappingId"); + if (key != null) { + otherAccounts.put(((Number) key).longValue(), account); + } + } + @SuppressWarnings("unchecked") + final List> ownAccounts = (List>) row.get("accounts"); + if (ownAccounts != null) { + for (final Map account : ownAccounts) { + final Object key = account.get("mappingId"); + final Map match = key == null ? null + : otherAccounts.get(((Number) key).longValue()); + // An account mapped since the other date has no row there. Left null rather than + // shown as zero: it did not hold nothing, it did not exist on the return. + final BigDecimal otherBalance = match == null ? null : (BigDecimal) match.get("balance"); + final BigDecimal ownBalance = (BigDecimal) account.get("balance"); + account.put("baseBalance", ownIsBase ? ownBalance : otherBalance); + account.put("currentBalance", ownIsBase ? otherBalance : ownBalance); + // The account's own unweighted balance at the other date too, so a weighted row + // still reconciles against the ledger in both columns. + final BigDecimal otherAccountBalance = match == null ? null + : (BigDecimal) match.get("accountBalance"); + final BigDecimal ownAccountBalance = (BigDecimal) account.get("accountBalance"); + account.put("baseAccountBalance", ownIsBase ? ownAccountBalance : otherAccountBalance); + account.put("currentAccountBalance", ownIsBase ? otherAccountBalance : ownAccountBalance); + if (ownBalance != null && otherBalance != null) { + final BigDecimal from = ownIsBase ? ownBalance : otherBalance; + final BigDecimal to = ownIsBase ? otherBalance : ownBalance; + account.put("movement", to.subtract(from)); + } + } + } + return row; + } + /** * Human-readable reasons the return could not be computed, for the screen to show verbatim. * @@ -935,4 +1070,1331 @@ public class RbiReserveReturnApiResource { private static LocalDate parseOrBusinessDate(final String param) { return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param); } + + /** + * Refuses a date the bank has not reached yet. + * + * A balance is summed as {@code entry_date <= date}, so a future date does not fail and does not + * come back empty - it returns TODAY'S balance under tomorrow's heading. The return then looks + * complete and is wrong in the one way nothing on its face can show: a fortnight that has not + * closed reported as though it had, with figures that will still move before it does. + * + * Checked against the business date rather than the wall clock, because that is the date every + * figure on the return is read at. + */ + private static LocalDate requirePastOrPresent(final LocalDate date, final String what) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + if (date.isAfter(today)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.future.date", + what + " (" + date + ") is in the future; the bank's business date is " + today + + ". A period that has not closed cannot be reported.", + what, date.toString(), today.toString()); + } + return date; + } + + /** The earlier of two dates. */ + private static LocalDate minimum(final LocalDate a, final LocalDate b) { + return a.isAfter(b) ? b : a; + } + + // ══════════════ Statutory Form 1 ══════════════ + + /** + * Form 1 as a co-operative bank actually files it - Banking Regulation (Co-operative Societies) + * Rules 1966, Form 1 under Rule 5, sections 18(1) and 24(3). + * + *

+ * This is a different statement from the {@code form1} endpoint above, not a reformatting of + * it. Three differences matter: + * + *

    + *
  • Three columns. The form reports two alternate Fridays side by side with the + * reporting date, so a reviewer sees the position move across the fortnight rather than a + * single snapshot.
  • + *
  • The base date. Both ratios are charged on the liabilities of the last Friday of + * the SECOND PRECEDING fortnight, not on the column's own liabilities. That lag is what gives a + * bank time to arrange its position, and reading the rate off the current column would report a + * requirement the bank never had.
  • + *
  • One form, both ratios. Part B settles section 18 (cash reserve) and Part C + * section 24 (liquid assets) on the same liabilities, so they are computed together.
  • + *
+ * + *

+ * Every figure is read from the heads an administrator mapped; nothing is hardcoded to a GL + * code. A head left unmapped reports as nil, because on this form a nil line is a real answer - + * a bank with no balances at the State Co-operative Bank files that line empty. + */ + @GET + @Path("form1-statutory") + public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("firstColumnDate") final String firstColumnDateParam, + @QueryParam("secondColumnDate") final String secondColumnDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requestedDate = parseOrBusinessDate(reportingDateParam); + + // The two alternate Fridays the form reports, and the reporting date itself. The filed form + // shows the earlier Friday first, so the columns read left to right in time order. + // Fortnights run 1st-15th and 16th-month end, and each closes on its last day. Those closes + // are what the form's columns report, not alternate Fridays: a filed Form 1 for August + // heads its columns 15/08 and 31/08, which are the fortnight ends however the weekdays + // fall. The reporting date itself is the third column. + // + // Both closes can be given outright. A bank filing August wants 15/08 and 31/08 on the face + // of the form whatever today is, and deriving them from one date cannot produce a pair that + // spans a month boundary or skips a fortnight - which is exactly what a late or corrected + // filing needs. Given explicitly they are used as given; otherwise they are derived as + // before. + // + // Both are refused if they have not happened yet: the figures under a future close are + // today's, and a fortnight still running would be filed as though it had closed. + final LocalDate currentClose = secondColumnDateParam == null || secondColumnDateParam.isBlank() + // Derived, the close can legitimately fall ahead of today - it is the fortnight the + // requested date sits inside. Pulled back to the business date so the form reports + // the position as far as it exists rather than refusing its own default. + ? minimum(fortnightCloseOnOrAfter(requestedDate), DateUtils.getBusinessLocalDate()) + : requirePastOrPresent(LocalDate.parse(secondColumnDateParam), "The second column's date"); + final LocalDate previousClose = firstColumnDateParam == null || firstColumnDateParam.isBlank() + ? fortnightCloseBefore(currentClose) + : requirePastOrPresent(LocalDate.parse(firstColumnDateParam), "The first column's date"); + + // The form is made up to the later of its two closes. Taking the reporting date separately + // let a caller pass one that belonged to a different fortnight than the columns, and the + // rate in force was then looked up on that stray date - so a form could be headed one + // period and computed at another period's rate, with nothing on it to show the mismatch. + final LocalDate reportingDate = currentClose; + // The form reads left to right as the period advances. Reversed, every movement across it + // carries the wrong sign while the form still looks properly filled in. + if (!previousClose.isBefore(currentClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.columns.out.of.order", + "The first column (" + previousClose + ") must fall before the second (" + currentClose + ").", + previousClose.toString(), currentClose.toString()); + } + + // The third column is the base the current fortnight's ratios are charged on - the last + // Friday of the second preceding fortnight - not the reporting date. + // + // It was the reporting date. Since the return is now made up to the second column, that + // made the third column a copy of the second: two identically dated columns carrying + // identical figures, which reads as a filled form and says nothing. The base belongs on the + // face of the form because every required amount below is computed from it, and a reviewer + // cannot check a requirement whose base is not stated. + final LocalDate currentBase = formOneBaseDate(currentClose); + final List columnDates = List.of(previousClose, currentClose, currentBase); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_1_STATUTORY"); + response.put("formName", "Form 1 (Rule 5) — Sections 18(1) and 24(3)"); + response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966"); + response.put("reportingDate", reportingDate.toString()); + response.put("columns", columnDates.stream().map(LocalDate::toString).toList()); + + final Rate crr = rateInForce("CRR", reportingDate); + final Rate slr = rateInForce("SLR", reportingDate); + response.put("crrPercentage", crr == null ? null : crr.percentage); + response.put("slrPercentage", slr == null ? null : slr.percentage); + + final List> parts = new ArrayList<>(); + parts.add(formOnePartA(columnDates, officeId, crr, slr)); + response.put("partA", parts.get(0)); + response.put("partB", formOnePartB(columnDates, officeId, crr)); + response.put("partC", formOnePartC(columnDates, officeId, slr)); + + final List unmet = new ArrayList<>(); + if (crr == null) { + unmet.add("No CRR rate is on record effective on or before the reporting date."); + } + if (slr == null) { + unmet.add("No SLR rate is on record effective on or before the reporting date."); + } + + // A nil base makes both required amounts nil, and the form then reads as a bank with no + // reserve requirement at all - which is what a bank in breach would also look like. The + // base is dated well before the columns, so a ledger that only starts part-way through can + // have real liabilities on its face and nothing behind the ratio, and nothing on the form + // says why. Named here with the date, so the nil is read as "no liabilities on that day" + // rather than as "nothing required". + final BigDecimal baseLiabilities = formOneNetLiabilities(currentBase, officeId); + if (baseLiabilities.compareTo(BigDecimal.ZERO) == 0) { + unmet.add("Both ratios are charged on the net liabilities of " + currentBase + + " — the last Friday of the second preceding fortnight — and the ledger holds " + + "none on that date. The required amounts are therefore nil. They are nil " + + "because the base is nil, not because nothing is required."); + } + + response.put("unmetRequirements", unmet); + return gson.toJson(response); + } + + /** Part A: the liabilities and assets the two ratios are worked out from. */ + private Map formOnePartA(final List dates, final Long officeId, final Rate crr, + final Rate slr) { + final List> lines = new ArrayList<>(); + + lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM")); + lines.add(formOneHeading("(a) Demand Liabilities")); + final List iai = formOneLine(lines, "(i) Current account balances of SBI, its subsidiaries and the corresponding new banks", + "FORM1_I_A_I_SBI_CURRENT", dates, officeId, 2); + final List iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system", + "FORM1_I_A_II_OTHER_DEMAND_BS", dates, officeId, 2); + final List ib = formOneLine(lines, "(b) Time liabilities to the banking system", "FORM1_I_B_TIME_BS", + dates, officeId, 1); + final List totalI = sumOf(iai, iaii, ib); + lines.add(formOneTotal("TOTAL OF I", totalI)); + + lines.add(formOneHeading("II. LIABILITIES IN INDIA TO OTHERS")); + // Demand and time come from the one DTL head, split by the class each mapped account + // carries - the same split the fortnightly return reports. + final List iiA = new ArrayList<>(); + final List iiB = new ArrayList<>(); + for (final LocalDate date : dates) { + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", date, officeId); + iiA.add(dtl.demandAmount); + iiB.add(dtl.timeAmount); + } + lines.add(formOneRow("(A) Demand liabilities", "DEMAND_TIME_LIABILITIES", iiA, 1)); + lines.add(formOneRow("(B) Time liabilities", "DEMAND_TIME_LIABILITIES", iiB, 1)); + final List totalII = sumOf(iiA, iiB); + lines.add(formOneTotal("TOTAL OF II", totalII)); + + lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM")); + final List iiiA = formOneLine(lines, "(A) Current account balances with SBI, its subsidiaries and the corresponding new banks", + "FORM1_III_A_SBI_CURRENT", dates, officeId, 1); + lines.add(formOneHeading("(B) Other assets with the banking system")); + final List iiiBi = formOneLine(lines, "(i) Balances in all accounts other than III(A)", + "FORM1_III_B_I_OTHER_BALANCES", dates, officeId, 2); + final List iiiBii = formOneLine(lines, "(ii) Money at call and short notice", + "FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2); + final List iiiBiv = formOneLine(lines, "(iv) Advances and any other assets", + "FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2); + final List totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv); + lines.add(formOneTotal("TOTAL OF III", totalIII)); + + // IV: the banking system nets out, and what is left is added to the liabilities to others. + // Floored at zero per column - the form's own wording, "(I - III) + II if minus figure", + // says the netting cannot push the base below the liabilities to others. + final List iv = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + iv.add(totalI.get(i).subtract(totalIII.get(i)).max(BigDecimal.ZERO).add(totalII.get(i))); + } + lines.add(formOneTotal("IV. TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24", iv)); + + final List v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0); + + lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH")); + final List viA = formOneLine(lines, "(a) Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", dates, + officeId, 1); + final List viB = formOneLine(lines, "(b) State Co-operative Bank of the state concerned", + "FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1); + final List viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned", + "FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1); + final List totalVI = sumOf(viA, viB, viC); + lines.add(formOneTotal("TOTAL OF VI", totalVI)); + + lines.add(formOneHeading("VII. BALANCES OF ALL OTHER TYPES WITH")); + final List viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned", + "FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1); + final List viiB = formOneLine(lines, "(b) District Central Co-operative Bank", + "FORM1_VII_B_DCCB_OTHER", dates, officeId, 1); + final List viiC = formOneLine(lines, "(c) Investment in Government securities", + "FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1); + final List totalVII = sumOf(viiA, viiB, viiC); + lines.add(formOneTotal("TOTAL OF VII", totalVII)); + + // VIII: only the excess counts. Where the banking system's claim on this bank exceeds the + // bank's own balance there is nothing to carry into the cash reserve, so it floors at zero. + final List viii = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + viii.add(iiiA.get(i).subtract(iai.get(i)).max(BigDecimal.ZERO)); + } + lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii)); + + // The liabilities the ratios are charged on: the last Friday of the second preceding + // fortnight, read per column rather than once, so each column states its own base. + final List baseDateDtl = new ArrayList<>(); + final List baseDates = new ArrayList<>(); + for (final LocalDate date : dates) { + final LocalDate base = formOneBaseDate(date); + baseDates.add(base.toString()); + baseDateDtl.add(formOneNetLiabilities(base, officeId)); + } + final Map baseRow = formOneTotal( + "DEMAND AND TIME LIABILITIES OF THE BASE DATE — last Friday of the second preceding fortnight", + baseDateDtl); + baseRow.put("baseDates", baseDates); + lines.add(baseRow); + + final Map part = new LinkedHashMap<>(); + part.put("title", "PART A"); + part.put("lines", lines); + part.put("baseDates", baseDates); + part.put("baseDateLiabilities", baseDateDtl); + return part; + } + + /** Part B: compliance with section 18 — the cash reserve. */ + private Map formOnePartB(final List dates, final Long officeId, final Rate crr) { + final List> lines = new ArrayList<>(); + final List required = new ArrayList<>(); + final List maintained = new ArrayList<>(); + + for (final LocalDate date : dates) { + final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId); + required.add(crr == null ? BigDecimal.ZERO + : base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP)); + // X = V + VI + VIII, the form's own definition. + final BigDecimal cash = figureFor("CASH_IN_HAND", date, officeId).amount; + final BigDecimal currentAccounts = figureFor("FORM1_VI_A_RBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_VI_B_STATE_COOP_CURRENT", date, officeId).amount) + .add(figureFor("FORM1_VI_C_DCCB_CURRENT", date, officeId).amount); + final BigDecimal netCurrent = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount + .subtract(figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount).max(BigDecimal.ZERO); + maintained.add(cash.add(currentAccounts).add(netCurrent)); + } + + lines.add(formOneTotal("IX. " + (crr == null ? "CRR" : crr.percentage + "%") + + " of IV as on the last Friday of the second preceding fortnight", required)); + lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained)); + + final Map part = new LinkedHashMap<>(); + part.put("title", "PART B — Compliance with Section 18"); + part.put("lines", lines); + part.put("required", required); + part.put("maintained", maintained); + part.put("surplus", differenceOf(maintained, required)); + return part; + } + + /** Part C: compliance with section 24 — the liquid assets. */ + private Map formOnePartC(final List dates, final Long officeId, final Rate slr) { + final List> lines = new ArrayList<>(); + final List required = new ArrayList<>(); + final List cashAndOther = new ArrayList<>(); + final List gold = new ArrayList<>(); + final List securities = new ArrayList<>(); + + for (final LocalDate date : dates) { + final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId); + final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO + : base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + required.add(slrRequired); + + // XII(a) is the cash reserve held OVER the section 18 requirement, plus the other + // balances of part VII. Only the excess counts: the part of the cash reserve that meets + // section 18 is already committed and cannot also meet section 24. + final BigDecimal crrRequired = formOnePartBRequired(date, officeId); + final BigDecimal crrMaintained = formOnePartBMaintained(date, officeId); + final BigDecimal excessCash = crrMaintained.subtract(crrRequired).max(BigDecimal.ZERO); + final BigDecimal otherBalances = figureFor("FORM1_VII_A_STATE_COOP_OTHER", date, officeId).amount + .add(figureFor("FORM1_VII_B_DCCB_OTHER", date, officeId).amount) + .add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount); + cashAndOther.add(excessCash.add(otherBalances)); + gold.add(figureFor("FORMA_SLR_GOLD", date, officeId).amount); + securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount); + } + + lines.add(formOneTotal("XI. " + (slr == null ? "SLR" : slr.percentage + "%") + + " of IV as on the last Friday of the second preceding fortnight", required)); + lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED")); + lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1)); + lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1)); + lines.add(formOneRow("(c) Unencumbered approved securities", "SLR_INVESTMENTS", securities, 1)); + final List totalXII = sumOf(cashAndOther, gold, securities); + lines.add(formOneTotal("TOTAL OF XII", totalXII)); + + final Map part = new LinkedHashMap<>(); + part.put("title", "PART C — Compliance with Section 24"); + part.put("lines", lines); + part.put("required", required); + part.put("maintained", totalXII); + part.put("surplus", differenceOf(totalXII, required)); + return part; + } + + private BigDecimal formOnePartBRequired(final LocalDate date, final Long officeId) { + final Rate crr = rateInForce("CRR", date); + if (crr == null) { + return BigDecimal.ZERO; + } + return formOneNetLiabilities(formOneBaseDate(date), officeId).multiply(crr.percentage).divide(HUNDRED, 2, + RoundingMode.HALF_UP); + } + + private BigDecimal formOnePartBMaintained(final LocalDate date, final Long officeId) { + final BigDecimal cash = figureFor("CASH_IN_HAND", date, officeId).amount; + final BigDecimal currentAccounts = figureFor("FORM1_VI_A_RBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_VI_B_STATE_COOP_CURRENT", date, officeId).amount) + .add(figureFor("FORM1_VI_C_DCCB_CURRENT", date, officeId).amount); + final BigDecimal netCurrent = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount + .subtract(figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount).max(BigDecimal.ZERO); + return cash.add(currentAccounts).add(netCurrent); + } + + /** + * The date both ratios are charged on: the last Friday of the SECOND PRECEDING fortnight. + * + *

+ * Counted in fortnights, not in weeks. For a column closing 15 August the fortnight it belongs + * to is 1-15 August, the first preceding one is 16-31 July, and the second is 1-15 July - so + * the base falls in the first half of July, roughly six weeks back rather than the four a + * straight subtraction would give. Reading the rate off a date a fortnight too late would + * report a requirement the bank never had time to meet, which is the whole point of the lag. + * + *

+ * Within that fortnight the form takes its last Friday, shifted off a holiday the same way + * every other reporting date is. + */ + private LocalDate formOneBaseDate(final LocalDate columnDate) { + final LocalDate ownClose = fortnightCloseOnOrAfter(columnDate); + final LocalDate secondPrecedingClose = fortnightCloseBefore(fortnightCloseBefore(ownClose)); + return precedingWorkingDay(reportingFridayOnOrBefore(secondPrecedingClose)); + } + + /** + * The close of the fortnight a date falls in: the 15th for the first half of a month, the last + * day of the month for the second. + */ + private static LocalDate fortnightCloseOnOrAfter(final LocalDate date) { + return date.getDayOfMonth() <= 15 ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth()); + } + + /** The close of the fortnight before the one ending on the given close. */ + private static LocalDate fortnightCloseBefore(final LocalDate close) { + return close.getDayOfMonth() <= 15 + // First half of a month: the fortnight before it ends with the previous month. + ? close.minusMonths(1).with(TemporalAdjusters.lastDayOfMonth()) + // Second half: the fortnight before it ends on the 15th of the same month. + : close.withDayOfMonth(15); + } + + /** Item IV computed for one date: (I − III floored at zero) + II. */ + private BigDecimal formOneNetLiabilities(final LocalDate date, final Long officeId) { + final BigDecimal toBankingSystem = figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_I_A_II_OTHER_DEMAND_BS", date, officeId).amount) + .add(figureFor("FORM1_I_B_TIME_BS", date, officeId).amount); + final BigDecimal withBankingSystem = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_III_B_I_OTHER_BALANCES", date, officeId).amount) + .add(figureFor("FORM1_III_B_II_MONEY_AT_CALL_BS", date, officeId).amount) + .add(figureFor("FORM1_III_B_IV_ADVANCES_BS", date, officeId).amount); + final BigDecimal toOthers = figureFor("DEMAND_TIME_LIABILITIES", date, officeId).amount; + return toBankingSystem.subtract(withBankingSystem).max(BigDecimal.ZERO).add(toOthers); + } + + // ── Form 1 row builders ── + + private static Map formOneHeading(final String label) { + final Map row = new LinkedHashMap<>(); + row.put("label", label); + row.put("heading", true); + return row; + } + + private static Map formOneRow(final String label, final String configKey, + final List values, final int indent) { + final Map row = new LinkedHashMap<>(); + row.put("label", label); + row.put("configKey", configKey); + row.put("indent", indent); + row.put("values", values); + return row; + } + + private static Map formOneTotal(final String label, final List values) { + final Map row = formOneRow(label, null, values, 0); + row.put("total", true); + return row; + } + + /** Reads one head across every column and appends its row. */ + private List formOneLine(final List> lines, final String label, + final String configKey, final List dates, final Long officeId, final int indent) { + final List values = new ArrayList<>(); + boolean configured = false; + for (final LocalDate date : dates) { + final RoleFigure figure = figureFor(configKey, date, officeId); + configured |= figure.configured; + values.add(figure.amount); + } + final Map row = formOneRow(label, configKey, values, indent); + row.put("configured", configured); + lines.add(row); + return values; + } + + @SafeVarargs + private static List sumOf(final List... columns) { + final List out = new ArrayList<>(); + final int size = columns.length == 0 ? 0 : columns[0].size(); + for (int i = 0; i < size; i++) { + BigDecimal total = BigDecimal.ZERO; + for (final List column : columns) { + total = total.add(column.get(i)); + } + out.add(total); + } + return out; + } + + private static List differenceOf(final List a, final List b) { + final List out = new ArrayList<>(); + for (int i = 0; i < a.size(); i++) { + out.add(a.get(i).subtract(b.get(i))); + } + return out; + } + + /** + * Form 9 in the shape it is filed - the statutory statement of compliance with section 24. + * + *

+ * The same Part A and Part C the statutory Form 1 carries, on this return's own dates. Form 1 + * reports two fortnight closes because it is filed fortnightly; Form 9 is filed monthly, so it + * reports the month it covers and the month before, and charges the ratio on the last Friday of + * the second preceding fortnight exactly as Form 1 does. + * + *

+ * Part B is left out. That part settles section 18, which is the cash reserve - Form 1's + * subject, not this one's. Printing it here would put a CRR compliance statement on an SLR + * return, where a reader has no reason to expect one. + * + * @param reportingDate any date in the month; the return reports that month's end. + * @param officeId Optional - scopes every figure to one office. + */ + @GET + @Path("form9-statutory") + public String form9Statutory(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); + final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + final LocalDate today = DateUtils.getBusinessLocalDate(); + // A month end that has not arrived would report today's balances under its heading - a month + // still running filed as a closed one. + final LocalDate currentClose = minimum(nominalMonthEnd, today); + final boolean monthComplete = !nominalMonthEnd.isAfter(today); + final LocalDate previousClose = requested.withDayOfMonth(1).minusDays(1); + final LocalDate base = formOneBaseDate(currentClose); + + final List columnDates = List.of(previousClose, currentClose, base); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_9_STATUTORY"); + response.put("formName", "Form 9 - Statement of Compliance with Section 24 (SLR)"); + response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966"); + response.put("frequency", "Monthly"); + response.put("reportingDate", currentClose.toString()); + response.put("monthEnd", nominalMonthEnd.toString()); + response.put("monthComplete", monthComplete); + response.put("columns", columnDates.stream().map(LocalDate::toString).toList()); + + final Rate crr = rateInForce("CRR", currentClose); + final Rate slr = rateInForce("SLR", currentClose); + response.put("crrPercentage", crr == null ? null : crr.percentage); + response.put("slrPercentage", slr == null ? null : slr.percentage); + + // Part A establishes the liabilities both ratios are charged on, so it is needed here even + // though only section 24 is settled below: without it the required amount at XI is a figure + // with nothing behind it. + response.put("partA", formOnePartA(columnDates, officeId, crr, slr)); + response.put("partC", formOnePartC(columnDates, officeId, slr)); + + final List unmet = new ArrayList<>(); + if (slr == null) { + unmet.add("No SLR rate is on record effective on or before the reporting date."); + } + if (crr == null) { + // Not an aside: XII(a) counts only the cash reserve held OVER the section 18 + // requirement, so without a CRR rate that line takes the whole reserve and overstates + // what is available to meet section 24. + unmet.add("No CRR rate is on record. The cash counted at XII(a) is the reserve held over " + + "the section 18 requirement, so without a CRR rate that line is overstated."); + } + if (!monthComplete) { + unmet.add("The month ending " + nominalMonthEnd + " has not closed. This is the position " + + "as at " + currentClose + ", not the month-end position the return reports."); + } + // As on Form 1: a nil base makes the requirement nil, and the form then reads the same as a + // bank with nothing to maintain. Said with the date so the nil can be read for what it is. + if (formOneNetLiabilities(base, officeId).compareTo(BigDecimal.ZERO) == 0) { + unmet.add("The ratio is charged on the net liabilities of " + base + " — the last Friday " + + "of the second preceding fortnight — and the ledger holds none on that date. " + + "The required amount is therefore nil, because the base is nil."); + } + response.put("unmetRequirements", unmet); + return gson.toJson(response); + } + + // ══════════════ Form IX - Return on Assets and Liabilities (R408) ══════════════ + + /** Rupees to rupees-thousands. Form IX reports in thousands; Form 1 reports in rupees. */ + private static final BigDecimal THOUSAND = BigDecimal.valueOf(1000); + + /** role_group for Form IX heads, so they sit apart from Form 1's on the configuration screen. */ + private static final String GROUP_FORM9 = "FORM9"; + + private static final String USAGE_ASSET = "ASSET"; + + /** + * Form IX - the Return on Assets and Liabilities a co-operative bank files monthly, return code + * R408. + * + *

+ * A different return from {@code form9} above despite the name. That one reports a ratio: the + * liquid assets held against the liabilities, and whether the bank complied. This one reports + * the bank's whole balance sheet - every class of liability and every class of asset - and its + * two sides must agree. + * + *

+ * Three things about it differ from every other return here: + * + *

    + *
  • Thousands. Every figure is in rupees thousands. The conversion happens once, on a + * finished total, never per account - see {@code thousands}.
  • + *
  • Unmapped heads still print. Elsewhere a head with nothing mapped is left off. Here + * a nil line is a real answer - a bank with no borrowings from the EXIM Bank files that line + * empty - so every head appears, with whether it was configured stated beside it.
  • + *
  • Both sides are totalled independently and their difference reported. The filed + * return this was built from balanced its sides by subtracting the difference from the side + * that was already short, and printed "DIFF ADJUSTED" rather than saying the balance did not + * hold. A figure is never adjusted here to make the form balance.
  • + *
+ * + * @param reportingDate any date in the month; the return reports that month's end. + * @param officeId Optional - scopes every figure to one office. + */ + @GET + @Path("form9-return") + public String form9Return(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); + final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + final LocalDate today = DateUtils.getBusinessLocalDate(); + // Run inside the current month, the month end has not arrived. Reading balances at it would + // return today's figures under the month-end heading - a month still running reported as a + // closed one, with nothing on the return to say so. + final LocalDate asOn = minimum(nominalMonthEnd, today); + final boolean monthComplete = !nominalMonthEnd.isAfter(today); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_9_RETURN"); + response.put("formName", "Form IX - Return on Assets and Liabilities"); + response.put("returnCode", "R408"); + response.put("statutoryReference", "The Banking Regulation Act - Form IX"); + response.put("frequency", "Monthly"); + response.put("unit", "Rs. in Thousands"); + response.put("reportingDate", asOn.toString()); + response.put("monthEnd", nominalMonthEnd.toString()); + response.put("monthComplete", monthComplete); + response.put("note", monthComplete + ? "Every figure is a closing balance as at " + asOn + ", in rupees thousands." + : "The month ending " + nominalMonthEnd + " has not closed. Figures are the position " + + "as at " + asOn + " and will move before the month ends."); + + // Every Form IX head, mapped or not, in the order the form prints them. + final List> roles = jdbcTemplate.queryForList(""" + SELECT config_key, label, role_usage + FROM m_rbi_reserve_role + WHERE role_group = ? + ORDER BY position, label + """, GROUP_FORM9); + + final Map> byKey = new LinkedHashMap<>(); + BigDecimal liabilityTotal = BigDecimal.ZERO; + BigDecimal assetTotal = BigDecimal.ZERO; + final List> liabilityLines = new ArrayList<>(); + final List> assetLines = new ArrayList<>(); + final List> memoLines = new ArrayList<>(); + int unmappedCount = 0; + + for (final Map role : roles) { + final String key = (String) role.get("config_key"); + final String usage = (String) role.get("role_usage"); + final RoleFigure figure = figureFor(key, asOn, officeId); + + final Map row = new LinkedHashMap<>(); + row.put("configKey", key); + row.put("label", role.get("label")); + row.put("usage", usage); + row.put("configured", figure.configured); + // Rupees as well as thousands. The rupee figure is what the ledger actually holds, and a + // reviewer checking a line against the trial balance needs it: a thousands figure alone + // cannot be reconciled, because the rounding already happened. + row.put("amountInRupees", figure.configured ? figure.amount : null); + row.put("amount", figure.configured ? thousands(figure.amount) : null); + row.put("accounts", accountsInThousands(figure)); + byKey.put(key, row); + + if (!figure.configured) { + unmappedCount++; + } + + if (USAGE_LIABILITY.equals(usage)) { + liabilityLines.add(row); + liabilityTotal = liabilityTotal.add(figure.amount); + } else if (USAGE_ASSET.equals(usage)) { + assetLines.add(row); + assetTotal = assetTotal.add(figure.amount); + } else { + memoLines.add(row); + } + } + + // A loss belongs on the asset side at item 12, not on the liability side as a negative + // item 9. The form carries two separate lines because the year's result goes to one of them + // or the other, never to both: a bank that made a loss files item 9 empty and item 12 with + // the figure. Reported as a negative liability instead it pulls the liability total DOWN by + // the loss, when a loss makes the two sides agree by being added to the assets - so the + // return came out short by twice the loss and said the balance did not hold. + // + // Moved rather than left with a caveat, because this one has a right answer: the figure is + // correct, only its side is wrong, and the form itself says which side that is. + // ── The year's own result ── + // + // Income and expense accounts hold the year's trading, and no balance-sheet head maps them + // because they are not balance-sheet accounts. But their NET is - it is the profit or loss + // the balance sheet closes with, and without it the two sides cannot agree by exactly that + // amount. + // + // What the equity profit accounts hold is LAST year's result and what has been appropriated + // out of it - a year-end closing entry moved it there when the books were closed, and the + // dividend and reserve transfers drew it down. That is a balance-sheet figure in its own + // right and is already reported through the accounts mapped to this head. + // + // This year's trading has not been closed to anywhere, so it is added on top. Adding it to + // what the equity accounts hold is not double counting: the year-end entry that would move + // it there has not run, which is exactly why the two sides were out by this amount. + // + // The whole figure - brought forward plus this year - then goes to ONE of the two lines the + // form carries for it. Positive it is item 9 on the liability side, negative it is item 12 + // on the asset side, and never a share of each. Two passes were tried, one adding the result + // and a second moving a negative across, and the second subtracted the whole balance from a + // total the first had already adjusted - so the return moved by the result twice. + final BigDecimal tradingResult = unclosedTradingResult(asOn, officeId); + final Map profitRow = byKey.get("FORM9_L9_BALANCE_OF_PROFIT"); + final Map lossRow = byKey.get("FORM9_A12_BALANCE_OF_LOSS"); + if (profitRow != null) { + final BigDecimal broughtForward = profitRow.get("amountInRupees") == null ? BigDecimal.ZERO + : (BigDecimal) profitRow.get("amountInRupees"); + final BigDecimal result = broughtForward.add(tradingResult); + + // Whatever the profit head contributed to the liability total in the loop above is taken + // back out, so the result can be placed once, on whichever side it belongs. + liabilityTotal = liabilityTotal.subtract(broughtForward); + + // Two figures, each named: what the equity accounts hold, and what the income and + // expense accounts still hold because no year-end entry has closed them. A reviewer + // checking this line against the ledger needs both, since neither account alone shows it. + final String provenance = "Equity accounts mapped here: " + rupees(broughtForward) + + ". Income and expense accounts not yet closed to equity: " + rupees(tradingResult) + "."; + + if (result.compareTo(BigDecimal.ZERO) < 0 && lossRow != null) { + final BigDecimal loss = result.negate(); + assetTotal = assetTotal.add(loss); + + // The accounts move WITH the figure, they are not copied to both lines. Item 9 now + // reports nil and has nothing to show; listing its accounts under a nil total said + // the bank held a profit made up of two balances that came to nothing. Worse, they + // were also printed under item 12 with their signs flipped, so the same account + // appeared twice with opposite figures and read as counted twice. + final Object accountsBehind = profitRow.get("accounts"); + profitRow.put("amountInRupees", BigDecimal.ZERO); + profitRow.put("amount", BigDecimal.ZERO); + profitRow.put("accounts", List.of()); + profitRow.put("note", "Nil — the bank stands in loss, so the figure is at item 12 on " + + "the asset side. " + provenance); + + lossRow.put("configured", true); + lossRow.put("amountInRupees", loss); + lossRow.put("amount", thousands(loss)); + // Exactly as the ledger holds them, signs and all. A loss IS a debit balance on an + // equity account, so showing it as one is the honest reading - and the unclosed + // trading is added as its own row because no account carries it. The three add to + // the loss, read as negatives, which is what a reviewer tracing the figure expects. + lossRow.put("accounts", withTradingRow(accountsBehind, tradingResult)); + // The rows below carry the OPPOSITE sign to the head, because a loss reported as a + // positive figure on the asset side is made up of balances that are negative on the + // liability side. Saying so lets the screen head the column "as the ledger holds + // it", rather than leaving a reader to wonder how three negatives make a positive. + lossRow.put("accountsMirrorHead", true); + // Short, because the row above the balances already says they read the other way + // round. What belongs here is where the figure came from, not how to read it. + lossRow.put("note", "The bank stands in loss, so the figure is reported here rather " + + "than as a negative profit at item 9. " + provenance); + } else { + profitRow.put("configured", true); + profitRow.put("amountInRupees", result); + profitRow.put("amount", thousands(result)); + profitRow.put("note", provenance); + liabilityTotal = liabilityTotal.add(result); + } + } + + // ── Overdrawn holding accounts ── + // + // A liability that has been drawn past nil is not a liability at all: the bank has paid out + // more than it owed, and what is left is money owed BACK to it. On a balance sheet that is + // an asset, and the form has a line for exactly this - 11(i) Other Assets, tangible. + // + // Reported there rather than left as a negative liability. A negative on the liability side + // pulls the liability total down by the overdraw AND leaves the asset it really is off the + // asset side, so the balance breaks by twice the figure - the same way a loss did before it + // was moved to item 12. It is also unreadable: minus one thousand under "Other Demand + // Liabilities" looks like a small liability rather than a debt owed to the bank. + final Map otherAssets = byKey.get("FORM9_A11_I_TANGIBLE_ASSETS"); + if (otherAssets != null) { + BigDecimal overdrawn = BigDecimal.ZERO; + final List overdrawnAccounts = new ArrayList<>(); + for (final Map.Entry> keyed : byKey.entrySet()) { + // The profit head is exempt. Its accounts read negative because the bank is in + // loss, not because anyone drew them past nil, and the return has already carried + // that figure to item 12. Counting it here as well put the loss on the asset side + // twice - once at item 12 and once inside Other Assets - and inflated both sides of + // the return by it while still, by luck, agreeing. + if ("FORM9_L9_BALANCE_OF_PROFIT".equals(keyed.getKey())) { + continue; + } + final Map row = keyed.getValue(); + if (!USAGE_LIABILITY.equals(row.get("usage"))) { + continue; + } + @SuppressWarnings("unchecked") + final List> accounts = (List>) row.get("accounts"); + if (accounts == null) { + continue; + } + for (final Map account : accounts) { + final BigDecimal balance = (BigDecimal) account.get("balanceInRupees"); + if (balance == null || balance.compareTo(BigDecimal.ZERO) >= 0) { + continue; + } + overdrawn = overdrawn.add(balance.negate()); + overdrawnAccounts.add(account.get("glCode") + " " + account.get("name")); + } + } + + if (overdrawn.compareTo(BigDecimal.ZERO) > 0) { + // Both totals move by the same amount and in the same direction: the liability side + // loses a negative, the asset side gains the positive it always was. + liabilityTotal = liabilityTotal.add(overdrawn); + assetTotal = assetTotal.add(overdrawn); + + final BigDecimal existing = otherAssets.get("amountInRupees") == null ? BigDecimal.ZERO + : (BigDecimal) otherAssets.get("amountInRupees"); + final BigDecimal total = existing.add(overdrawn); + otherAssets.put("configured", true); + otherAssets.put("amountInRupees", total); + otherAssets.put("amount", thousands(total)); + otherAssets.put("note", "Includes " + rupees(overdrawn) + " rupees drawn out of holding accounts " + + "beyond what they held (" + String.join(", ", overdrawnAccounts) + "). A liability " + + "paid past nil is money owed back to the bank, which is an asset."); + } + } + + response.put("liabilityLines", liabilityLines); + response.put("assetLines", assetLines); + response.put("memoLines", memoLines); + + // ── Sub-totals the form states on its face ── + // + // Summed in RUPEES and converted once. Adding up figures that were each already rounded to + // thousands is what put the two sides of the filed return 2 thousand apart. + final Map totals = new LinkedHashMap<>(); + totals.put("paidUpCapital", sumThousands(byKey, "FORM9_L1_A_CAPITAL_INDIVIDUALS", + "FORM9_L1_B_CAPITAL_COOP_SOCIETIES", "FORM9_L1_C_CAPITAL_STATE_GOVT", "FORM9_L1_D_CAPITAL_OTHERS")); + totals.put("reserveFund", sumThousands(byKey, "FORM9_L2_A_STATUTORY_RESERVE", "FORM9_L2_B_AGRI_CREDIT_FUND", + "FORM9_L2_C_DIVIDEND_EQUALISATION", "FORM9_L2_D_SPECIAL_BAD_DEBT", "FORM9_L2_E_BAD_DOUBTFUL_RESERVE", + "FORM9_L2_F_INVESTMENT_DEPRECIATION", "FORM9_L2_G_OTHER_FUNDS")); + totals.put("partnershipFund", sumThousands(byKey, "FORM9_L3_A_PARTNERSHIP_CCB", "FORM9_L3_B_PARTNERSHIP_PACS", + "FORM9_L3_C_PARTNERSHIP_OTHER")); + + final BigDecimal demandRupees = sumRupees(byKey, "FORM9_L4_A1_CURRENT_CCB", "FORM9_L4_A2_CURRENT_PCB", + "FORM9_L4_A3_CURRENT_SOCIETIES", "FORM9_L4_A4_CURRENT_INDIVIDUALS", "FORM9_L4_A5_CURRENT_RESERVE_FUND", + "FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS", "FORM9_L4_B_SAVINGS_DEMAND_OTHER", + "FORM9_L4_C_BORROWINGS_DEMAND", "FORM9_L4_D_OTHER_DEMAND"); + final BigDecimal timeRupees = sumRupees(byKey, "FORM9_L5_A1_FD_CCB", "FORM9_L5_A2_FD_PCB", + "FORM9_L5_A3_FD_SOCIETIES", "FORM9_L5_A4_FD_INDIVIDUALS", "FORM9_L5_A5_FD_RESERVE_FUND", + "FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS", "FORM9_L5_B_SAVINGS_TIME_OTHER", "FORM9_L5_C_BORROWINGS_TIME", + "FORM9_L5_D_CASH_CERT_RD", "FORM9_L5_E_OTHER_TIME"); + final BigDecimal branchRupees = rupeesOf(byKey, "FORM9_L6_BRANCH_ADJUSTMENTS"); + + totals.put("demandLiabilities", thousands(demandRupees)); + totals.put("timeLiabilities", thousands(timeRupees)); + totals.put("branchAdjustments", thousands(branchRupees)); + // Item 8 of the form: demand + time + branch adjustments, the figure CRR and SLR are charged + // on elsewhere. Converted from the rupee sum, not from the three thousands figures above. + totals.put("totalDemandAndTime", thousands(demandRupees.add(timeRupees).add(branchRupees))); + totals.put("balanceOfProfit", sumThousands(byKey, "FORM9_L9_BALANCE_OF_PROFIT")); + totals.put("totalLiabilities", thousands(liabilityTotal)); + + totals.put("cashInHand", sumThousands(byKey, "FORM9_A1_CASH_IN_HAND")); + totals.put("balanceWithBanksCurrent", sumThousands(byKey, "FORM9_A2_A_CUR_RBI", "FORM9_A2_B_CUR_SBI", + "FORM9_A2_C_CUR_BANKING_SYSTEM", "FORM9_A2_D_CUR_STATE_COOP", "FORM9_A2_E_CUR_DCCB", + "FORM9_A2_F_CUR_OTHER_COOP")); + totals.put("balanceWithBanksSavings", sumThousands(byKey, "FORM9_A2_SAV_BANKING_SYSTEM", "FORM9_A2_SAV_COOP")); + totals.put("moneyAtCall", sumThousands(byKey, "FORM9_A3_MONEY_AT_CALL")); + totals.put("investments", sumThousands(byKey, "FORM9_A4_A1_CENTRAL_GOVT_SEC", "FORM9_A4_A2_STATE_GOVT_SEC", + "FORM9_A4_B_LAND_MORTGAGE_DEBENTURES", "FORM9_A4_C_OTHER_TRUSTEE_SEC", "FORM9_A4_D_SHARES_STATE_COOP", + "FORM9_A4_E_SHARES_DCCB", "FORM9_A4_F_SHARES_OTHER_COOP", "FORM9_A4_G1_FD_SBI", + "FORM9_A4_G2_FD_BANKING_SYSTEM", "FORM9_A4_G3_FD_STATE_COOP", "FORM9_A4_G4_FD_DCCB", + "FORM9_A4_G5_FD_OTHER_COOP", "FORM9_A4_H_OTHER_INVESTMENTS")); + final BigDecimal advancesRupees = sumRupees(byKey, "FORM9_A6_A_SEASONAL_AGRI", "FORM9_A6_B_MARKETING_CROPS", + "FORM9_A6_C_MEDIUM_TERM_AGRI", "FORM9_A6_D_WEAVERS_SOCIETIES", "FORM9_A6_E_INDUSTRIAL_SHORT", + "FORM9_A6_E_INDUSTRIAL_MEDIUM", "FORM9_A6_F_OTHER_SHORT", "FORM9_A6_F_OTHER_MEDIUM", + "FORM9_A6_F_OTHER_LONG"); + totals.put("loansAndAdvances", thousands(advancesRupees)); + totals.put("otherAssets", sumThousands(byKey, "FORM9_A11_I_TANGIBLE_ASSETS", "FORM9_A11_II_INTANGIBLE_ASSETS")); + totals.put("totalAssets", thousands(assetTotal)); + + // The form's two explanation percentages, computed from the advances they qualify. + final BigDecimal overdueRupees = rupeesOf(byKey, "FORM9_A6_G_OVERDUE_ADVANCES"); + final BigDecimal priorityRupees = rupeesOf(byKey, "FORM9_A6_G_PRIORITY_SECTOR"); + totals.put("overduePercentage", percentageOf(overdueRupees, advancesRupees)); + totals.put("prioritySectorPercentage", percentageOf(priorityRupees, advancesRupees)); + response.put("totals", totals); + + // ── Reconciliation ── + // + // Stated whether or not it holds. A return whose sides disagree is a return with something + // unmapped or double-mapped, and saying so is the only way anyone finds out which. + final BigDecimal differenceRupees = liabilityTotal.subtract(assetTotal); + final Map reconciliation = new LinkedHashMap<>(); + reconciliation.put("totalLiabilities", thousands(liabilityTotal)); + reconciliation.put("totalAssets", thousands(assetTotal)); + reconciliation.put("differenceInRupees", differenceRupees); + reconciliation.put("difference", thousands(differenceRupees)); + reconciliation.put("balanced", differenceRupees.compareTo(BigDecimal.ZERO) == 0); + response.put("reconciliation", reconciliation); + + final List caveats = new ArrayList<>(); + if (differenceRupees.compareTo(BigDecimal.ZERO) != 0) { + caveats.add("The two sides of the return do not agree: liabilities exceed assets by " + + rupees(differenceRupees) + " rupees. A head is unmapped, mapped twice, or mapped to the " + + "wrong side. The figures are left as the ledger reports them."); + } + + // A liability with a negative balance, or an asset with one, is always a ledger fault - the + // bank cannot owe less than nothing, nor hold less than nothing. It arrives as an ordinary + // figure on the face of the return and reads as a small number rather than as a broken one, + // and it is exactly the kind of thing that puts the two sides out of agreement. Named here + // so whoever reads the return knows which account to look at. + for (final Map.Entry> keyed : byKey.entrySet()) { + // The profit and loss heads are exempt. A bank that has made a loss holds it on an + // equity account as a debit, and that account reads negative for a perfectly good + // reason - the return has already moved the figure to item 12 on the asset side, where + // it belongs. Flagging it would report the year's loss as a ledger fault. + if ("FORM9_L9_BALANCE_OF_PROFIT".equals(keyed.getKey()) + || "FORM9_A12_BALANCE_OF_LOSS".equals(keyed.getKey())) { + continue; + } + final Map row = keyed.getValue(); + final String usage = (String) row.get("usage"); + final String side = USAGE_LIABILITY.equals(usage) ? "A liability" : USAGE_ASSET.equals(usage) ? "An asset" : "A head"; + + // Each ACCOUNT, not only the head's total. A negative account inside a head that totals + // positive is invisible in the total - "4(d) Other Demand Liabilities" reads 140,826 + // while one of the four accounts under it sits at minus a thousand - and that is the + // case most worth catching, because nothing about the printed figure looks wrong. + @SuppressWarnings("unchecked") + final List> accounts = (List>) row.get("accounts"); + boolean namedAnAccount = false; + if (accounts != null) { + for (final Map account : accounts) { + final BigDecimal balance = (BigDecimal) account.get("balanceInRupees"); + if (balance == null || balance.compareTo(BigDecimal.ZERO) >= 0) { + continue; + } + namedAnAccount = true; + final String where = USAGE_LIABILITY.equals(usage) + // Already carried to the asset side above, so the note says where it + // went rather than asking the reader to go and find it. + ? " It is reported at 11(i) Other Assets, because a liability paid past " + + "nil is money owed back to the bank." + : ""; + caveats.add(side + " has been drawn past nil: " + account.get("glCode") + " " + + account.get("name") + ", under \"" + row.get("label") + "\", stands at " + + rupees(balance) + " rupees. Either an entry was posted to it that should have " + + "gone elsewhere, or the entry that should have raised it never ran." + + where); + } + } + + // The head itself, where no account under it accounts for the sign - a derived figure, + // or a head whose accounts are individually fine but sum below zero. + final BigDecimal headBalance = (BigDecimal) row.get("amountInRupees"); + if (!namedAnAccount && headBalance != null && headBalance.compareTo(BigDecimal.ZERO) < 0) { + caveats.add(side + " cannot hold a negative balance: \"" + row.get("label") + "\" reports " + + rupees(headBalance) + " rupees. Either an entry was posted to it that should have gone " + + "elsewhere, or the entry that should have raised it never ran."); + } + } + // Named, not counted. A difference tells a filer THAT something is missing; this tells them + // WHICH account, which is the only form of the answer they can act on. Without it the gap + // has to be hunted down by reading the trial balance against the return line by line - and + // it comes back every time somebody opens a GL account and forgets to map it. + for (final Map account : unmappedAccountsWithBalances(asOn, officeId, byKey)) { + caveats.add("Not on this return: " + account.get("glCode") + " " + account.get("name") + " holds " + + rupees((java.math.BigDecimal) account.get("balance")) + " rupees but is mapped to no head, so it is counted on " + + "neither side. Map it under Admin, or the return stays out by this amount."); + } + + if (unmappedCount > 0) { + // Not an error. A nil line is a real answer on this return, so this says what it is - + // how much of the form has been configured - rather than calling it a failure. + caveats.add(unmappedCount + " of " + roles.size() + " heads have no GL account mapped and " + + "report nil. On this return a nil line is a real answer; map any head the bank " + + "does hold a balance under."); + } + if (!monthComplete) { + caveats.add("The month has not closed. This is the position so far, not the month-end " + + "position the return reports."); + } + response.put("caveats", caveats); + response.put("computable", true); + + return gson.toJson(response); + } + + /** + * Rupees to rupees-thousands, rounded half-up. + * + *

+ * Called on a finished total and never on an individual account. Rounding each account and then + * adding the results drifts by up to half a thousand per account, and on the filed return this + * was built from that drift put the two sides 2 thousand apart - which the report then hid + * behind a "DIFF ADJUSTED" line. + */ + /** + * Every balance-sheet account with a balance, and which head of this return reports it. + * + *

+ * A trial balance read through the return's own eyes. It exists because a difference between the + * two sides can only be chased by comparing what the ledger holds against what the return counts, + * and doing that by hand across a chart of accounts is where the time goes. Every figure here is + * read by the same code the return itself uses, so the two cannot disagree about an account. + */ + @GET + @Path("form9-trial-balance") + public String form9TrialBalance(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate asOn = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), "The reporting date") + .with(TemporalAdjusters.lastDayOfMonth()); + + // Which head, if any, claims each account - an account can be claimed by more than one, and + // that is itself worth seeing. + final Map> heads = new LinkedHashMap<>(); + for (final Map row : jdbcTemplate.queryForList(""" + SELECT c.gl_account_id, r.config_key, c.weight_percentage + FROM m_rbi_reserve_gl_config c + JOIN m_rbi_reserve_role r ON r.config_key = c.config_key + WHERE r.role_group = ? + """, GROUP_FORM9)) { + final Long accountId = ((Number) row.get("gl_account_id")).longValue(); + heads.computeIfAbsent(accountId, k -> new ArrayList<>()) + .add(row.get("config_key") + " @" + row.get("weight_percentage") + "%"); + } + + final StringBuilder sql = new StringBuilder(""" + SELECT a.id, a.gl_code, a.name, a.classification_enum, + COALESCE(SUM(CASE WHEN j.type_enum = 2 THEN j.amount ELSE 0 END), 0) AS total_debit, + COALESCE(SUM(CASE WHEN j.type_enum = 1 THEN j.amount ELSE 0 END), 0) AS total_credit + FROM acc_gl_account a + JOIN acc_gl_journal_entry j ON j.account_id = a.id + WHERE a.account_usage = 1 + AND j.entry_date <= ? + AND (j.reversed IS NULL OR j.reversed = false) + AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn))); + if (officeId != null) { + sql.append(" AND j.office_id = ?"); + args.add(officeId); + } + sql.append(" GROUP BY a.id, a.gl_code, a.name, a.classification_enum ORDER BY a.gl_code"); + + final List> accounts = new ArrayList<>(); + BigDecimal assets = BigDecimal.ZERO; + BigDecimal liabilities = BigDecimal.ZERO; + BigDecimal equity = BigDecimal.ZERO; + BigDecimal income = BigDecimal.ZERO; + BigDecimal expense = BigDecimal.ZERO; + + for (final Map row : jdbcTemplate.queryForList(sql.toString(), args.toArray())) { + final int classification = ((Number) row.get("classification_enum")).intValue(); + final BigDecimal debit = (BigDecimal) row.get("total_debit"); + final BigDecimal credit = (BigDecimal) row.get("total_credit"); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit); + if (balance.compareTo(BigDecimal.ZERO) == 0) { + continue; + } + switch (classification) { + case 1 -> assets = assets.add(balance); + case 2 -> liabilities = liabilities.add(balance); + case 3 -> equity = equity.add(balance); + case 4 -> income = income.add(balance); + default -> expense = expense.add(balance); + } + final Long id = ((Number) row.get("id")).longValue(); + final Map out = new LinkedHashMap<>(); + out.put("glCode", row.get("gl_code")); + out.put("name", row.get("name")); + out.put("classification", switch (classification) { + case 1 -> "ASSET"; + case 2 -> "LIABILITY"; + case 3 -> "EQUITY"; + case 4 -> "INCOME"; + default -> "EXPENSE"; + }); + out.put("balance", balance); + out.put("reportedUnder", heads.getOrDefault(id, List.of())); + accounts.add(out); + } + + final Map response = new LinkedHashMap<>(); + response.put("asOnDate", asOn.toString()); + response.put("accounts", accounts); + final Map totals = new LinkedHashMap<>(); + totals.put("assets", assets); + totals.put("liabilities", liabilities); + totals.put("equity", equity); + totals.put("income", income); + totals.put("expense", expense); + totals.put("tradingResult", income.subtract(expense)); + // Assets must equal liabilities plus equity plus the year's result. Where they do not, the + // ledger itself is out, and no return built on it can balance. + totals.put("assetsLessLiabilitiesEquityAndResult", + assets.subtract(liabilities).subtract(equity).subtract(income.subtract(expense))); + response.put("totals", totals); + return gson.toJson(response); + } + + /** + * The trading result the books have not yet closed: income less expense, as at the given date. + * + *

+ * Read from the income and expense accounts themselves rather than from any equity account. No + * balance-sheet head maps them, and rightly so - they are not balance-sheet accounts - but their + * net IS the profit or loss the balance sheet closes with. Left out, the two sides of the return + * disagree by exactly the year's result, and the figure that is missing is the one a reader + * would most want. + * + *

+ * No date range, and no rule about which year an entry belongs to. What matters is what the + * accounts still HOLD: a year-end closing entry nets the account it closes back to zero, so + * whatever has been closed already contributes nothing here, and whatever has not is still owed + * a place on the balance sheet however long ago it was earned. The ledger itself says which is + * which, and two attempts to say it another way were both wrong - filtering from 1 April left + * out earlier earnings that were never closed, and skipping entries with an equity leg also + * skipped the original income, whose own posting often carries one. + * + * @return income minus expense; negative is a loss. + */ + /** + * Balance-sheet accounts holding a balance that no head on this return maps. + * + *

+ * Every such account is a hole in the return of exactly its own balance, and the two sides + * cannot agree while one exists. The return already reports the difference; this says which + * account caused it, which is the only form of the answer anyone can act on. + * + *

+ * Income and expense accounts are excluded deliberately. They are not balance-sheet accounts and + * no head maps them; their net is reported as the year's result instead. + */ + private List> unmappedAccountsWithBalances(final LocalDate asOn, final Long officeId, + final Map> byKey) { + // Everything this return already accounts for, however many heads it is spread across. + final Set mapped = new HashSet<>(); + for (final Map row : byKey.values()) { + @SuppressWarnings("unchecked") + final List> accounts = (List>) row.get("accounts"); + if (accounts == null) { + continue; + } + for (final Map account : accounts) { + final Object id = account.get("glAccountId"); + if (id != null) { + mapped.add(((Number) id).longValue()); + } + } + } + + final StringBuilder sql = new StringBuilder(""" + SELECT a.id, a.gl_code, a.name, a.classification_enum, + COALESCE(SUM(CASE WHEN j.type_enum = 2 THEN j.amount ELSE -j.amount END), 0) AS debit_less_credit + FROM acc_gl_account a + JOIN acc_gl_journal_entry j ON j.account_id = a.id + WHERE a.classification_enum IN (1, 2, 3) + AND a.account_usage = 1 + AND j.entry_date <= ? + AND (j.reversed IS NULL OR j.reversed = false) + AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn))); + if (officeId != null) { + sql.append(" AND j.office_id = ?"); + args.add(officeId); + } + sql.append(" GROUP BY a.id, a.gl_code, a.name, a.classification_enum ORDER BY a.gl_code"); + + final List> out = new ArrayList<>(); + for (final Map row : jdbcTemplate.queryForList(sql.toString(), args.toArray())) { + final Long id = ((Number) row.get("id")).longValue(); + if (mapped.contains(id)) { + continue; + } + final int classification = ((Number) row.get("classification_enum")).intValue(); + final BigDecimal debitLessCredit = (BigDecimal) row.get("debit_less_credit"); + // On its natural side, so the figure reads the way the account itself does. + final BigDecimal balance = classification == ASSET ? debitLessCredit : debitLessCredit.negate(); + if (balance.compareTo(BigDecimal.ZERO) == 0) { + continue; + } + final Map account = new LinkedHashMap<>(); + account.put("glCode", row.get("gl_code")); + account.put("name", row.get("name")); + account.put("balance", balance); + out.add(account); + } + return out; + } + + private BigDecimal unclosedTradingResult(final LocalDate asOn, final Long officeId) { + // Income is credit-natured and expense debit-natured, so each is netted on its own side and + // the expense side subtracted: a credit to income adds to the result, a debit to expense + // takes from it. + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM( + CASE WHEN a.classification_enum = 4 + THEN CASE WHEN j.type_enum = 1 THEN j.amount ELSE -j.amount END + ELSE CASE WHEN j.type_enum = 2 THEN -j.amount ELSE j.amount END + END), 0) + FROM acc_gl_journal_entry j + JOIN acc_gl_account a ON a.id = j.account_id + WHERE a.classification_enum IN (4, 5) + AND j.entry_date <= ? + AND (j.reversed IS NULL OR j.reversed = false) + AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn))); + if (officeId != null) { + sql.append(" AND j.office_id = ?"); + args.add(officeId); + } + final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray()); + return result == null ? BigDecimal.ZERO : result; + } + + /** + * A head's accounts as the ledger holds them, with the unclosed trading result added as a row + * of its own. + * + *

+ * The balances are not restated. Flipping their signs to match the side the head is reported on + * was tried and reads as double counting: the same account then appears under item 9 at -512 and + * under item 12 at +512, and nothing on the form says they are one balance. A loss genuinely IS + * a debit balance on an equity account, so showing it as one is the honest reading, and the + * head's own total already states the figure the right way up for the side it sits on. + * + *

+ * The trading result appears as its own row because no account holds it: it is the net of the + * income and expense accounts, which the books have not closed to equity. Without it the rows + * are short by exactly that, and a reviewer adding them up finds a figure that is not the head. + */ + private static List> withTradingRow(final Object source, final BigDecimal tradingResult) { + final List> out = new ArrayList<>(); + if (source instanceof List accounts) { + for (final Object item : accounts) { + if (item instanceof Map account) { + final Map row = new LinkedHashMap<>(); + for (final Map.Entry field : account.entrySet()) { + row.put(String.valueOf(field.getKey()), field.getValue()); + } + out.add(row); + } + } + } + if (tradingResult != null && tradingResult.compareTo(BigDecimal.ZERO) != 0) { + final Map row = new LinkedHashMap<>(); + row.put("glAccountId", null); + row.put("glCode", "—"); + row.put("name", "Income and expense accounts not yet closed to equity"); + row.put("balanceInRupees", tradingResult); + row.put("balance", thousands(tradingResult)); + out.add(row); + } + return out; + } + + /** + * A rupee figure for a sentence a person reads. + * + *

+ * Trimmed to paise. A BigDecimal carries whatever scale the arithmetic that produced it left + * behind, and a note that says "-247784.820000 rupees" reads as a machine's working rather than + * as an amount of money. + */ + private static String rupees(final BigDecimal amount) { + return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static BigDecimal thousands(final BigDecimal rupees) { + return rupees == null ? null : rupees.divide(THOUSAND, 0, RoundingMode.HALF_UP); + } + + /** One head's rupee figure, or zero where it is unmapped. */ + private static BigDecimal rupeesOf(final Map> byKey, final String configKey) { + final Map row = byKey.get(configKey); + if (row == null) { + return BigDecimal.ZERO; + } + final BigDecimal amount = (BigDecimal) row.get("amountInRupees"); + return amount == null ? BigDecimal.ZERO : amount; + } + + /** Several heads added in rupees, for a total that is converted once afterwards. */ + private static BigDecimal sumRupees(final Map> byKey, final String... configKeys) { + BigDecimal total = BigDecimal.ZERO; + for (final String key : configKeys) { + total = total.add(rupeesOf(byKey, key)); + } + return total; + } + + /** Several heads added in rupees and stated in thousands. */ + private static BigDecimal sumThousands(final Map> byKey, final String... configKeys) { + return thousands(sumRupees(byKey, configKeys)); + } + + /** + * A head's accounts with their balances restated in thousands. + * + *

+ * These are the rows of the form's "specify" sheets - Other Funds and Reserves, Other Demand + * Liabilities, Other Time Liabilities, Other Investments. Each account mapped to such a head IS + * a row on its sheet, under its own name, so no separate machinery is needed to build them. + */ + private static List> accountsInThousands(final RoleFigure figure) { + final List> out = new ArrayList<>(); + for (final Map account : figure.accounts) { + final Map row = new LinkedHashMap<>(account); + final BigDecimal balance = (BigDecimal) account.get("balance"); + // Both kept: the rupee balance is what reconciles against the ledger, the thousands + // figure is what the sheet prints. An account row rounds on its own because it is + // printed on its own - it is never added into a total here. + row.put("balanceInRupees", balance); + row.put("balance", thousands(balance)); + out.add(row); + } + return out; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java index ab09e9143..3a8f0dea2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java @@ -53,7 +53,10 @@ import org.springframework.transaction.annotation.Transactional; @Slf4j public class RbiFormAAutoSaveService { - private static final String FORM_CODE = "FORM_1"; + // The statutory form, because that is the Form 1 the screen shows and the one that gets filed. + // Saving the working report's shape left rows in the list that opened as a blank statement: + // the viewer built for the statutory form found none of the fields it reads. + private static final String FORM_CODE = "FORM_1_STATUTORY"; private final JdbcTemplate jdbcTemplate; private final RbiReserveReturnApiResource returnApiResource; @@ -89,7 +92,11 @@ public class RbiFormAAutoSaveService { try { // Computed through the same endpoint the screen uses, so an auto-saved return and one // saved by hand cannot come out differently. - report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class); + // No column dates passed: today IS a fortnight close - the check above returned + // otherwise - so the form derives its own pair from it, which is the pair an unattended + // run should file. A filer needing a different period enters both dates on the screen. + report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null), + JsonObject.class); } catch (final RuntimeException e) { // A return that cannot be computed is not an error worth failing the batch over - an // unmapped role or a missing rate is a configuration gap, and the next run picks it up @@ -98,18 +105,22 @@ public class RbiFormAAutoSaveService { return; } - if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) { - log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.", + // The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil + // on it - a bank with no balances at the State Co-operative Bank files that line empty - so + // the form is filed as it stands. What it cannot do without is a rate, because every + // required amount below is a percentage of one, and saving a return whose requirement is + // zero for want of configuration would put a compliant-looking statement on the record. + if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) { + log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.", today); return; } - final LocalDate periodStart = date(report, "fortnightStart"); - final LocalDate periodEnd = date(report, "fortnightEnd"); - BigDecimal average = null; - if (report.has("maintenance") && report.get("maintenance").isJsonObject()) { - average = decimal(report.getAsJsonObject("maintenance"), "average"); - } + // The form's period runs between its two fortnight closes, and its headline figures sit per + // column inside the parts rather than once at the top. + final LocalDate periodStart = column(report, 0); + final LocalDate periodEnd = column(report, 1); + final BigDecimal average = null; jdbcTemplate.update(""" INSERT INTO m_rbi_form_a_filing @@ -119,9 +130,9 @@ public class RbiFormAAutoSaveService { VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now()) """, FORM_CODE, java.sql.Date.valueOf(today), java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd), - gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"), - decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"), - "Saved automatically at the close of the fortnight."); + gson.toJson(report), partFigure(report, "partA", "baseDateLiabilities"), + partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average, + decimal(report, "crrPercentage"), "Saved automatically at the close of the fortnight."); log.info("RBI Form A auto-save: saved the return for {} as a draft.", today); } @@ -143,6 +154,41 @@ public class RbiFormAAutoSaveService { return object.get(field).getAsBigDecimal(); } + /** + * One of the statutory form's per-column figures, at the close the return is made up to. + * + * Index 1, the second column: the third is the base the ratios are charged on, not a later + * position, and listing it would show a requirement against a fortnight nobody filed. + */ + private static BigDecimal partFigure(final JsonObject report, final String part, final String field) { + if (!report.has(part) || !report.get(part).isJsonObject()) { + return null; + } + final JsonObject partObject = report.getAsJsonObject(part); + if (!partObject.has(field) || !partObject.get(field).isJsonArray()) { + return null; + } + final var values = partObject.getAsJsonArray(field); + if (values.size() < 2 || values.get(1).isJsonNull()) { + return null; + } + return values.get(1).getAsBigDecimal(); + } + + /** One of the form's column dates. */ + private static LocalDate column(final JsonObject report, final int index) { + if (!report.has("columns") || !report.get("columns").isJsonArray()) { + return null; + } + final var columns = report.getAsJsonArray("columns"); + if (columns.size() <= index || columns.get(index).isJsonNull()) { + return null; + } + final String value = columns.get(index).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } + + @SuppressWarnings("unused") private static LocalDate date(final JsonObject object, final String field) { if (object == null || !object.has(field) || object.get(field).isJsonNull()) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java index f3d98a4a1..f66b2485a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; /** - * Runs the day: accrue for every account, then pay out any product whose cycle ended today. + * Runs the day's savings interest: accrue for every account, and post interest only from approved + * interest posting reports ({@link SavingsInterestPostingReportService}). * * Accrual and posting are separated on purpose. Accrual touches no money and can be repeated * freely; posting moves money and must not be. Keeping them apart means a posting that fails can @@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator { private final JdbcTemplate jdbcTemplate; private final SavingsDailyInterestService accrualService; private final SavingsPooledInterestPostingService postingService; + private final SavingsInterestPostingReportService reportService; + private final PlatformTransactionManager transactionManager; /** - * Accrue for the day, then post for any product whose posting date has arrived. + * The nightly run: settles any approved interest report still unposted, then accrues the day. * - * Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the - * posting then reads and updates those same rows in a transaction of its own - * ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back - * another's payment). Holding an outer transaction across both would mean this thread still owns - * the row locks the accrual took while the posting's separate transaction waits for them — each - * waiting on the other, and the job hanging silently with nothing in the log. So each step - * commits on its own before the next begins. + *

+ * This run no longer decides to pay anybody. Interest is paid only from an approved posting + * report, normally when the last branch closes the day. What is left here is the safety net for a + * report that was approved but could not be posted then, and it runs BEFORE the accrual on + * purpose: by the time this job runs the business date has already moved past the posting date, + * and the accrual rolls each account's ledger row into the next cycle, which would leave nothing + * for the posting to settle. + * + *

+ * Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the + * accrual then commits on its own, so neither holds the other's row locks. */ public void runForDate(final LocalDate businessDate) { clearStaleRunningFlag(); + final List failures = postApprovedReports(businessDate); accrualService.accrueForDate(businessDate); enrolNewProducts(); - final List> schedules = jdbcTemplate.queryForList(""" - SELECT s.savings_product_id, s.posting_period_enum - FROM m_savings_interest_posting_schedule s - JOIN m_savings_product p ON p.id = s.savings_product_id - WHERE s.is_active = true AND p.deposit_type_enum = 100 - ORDER BY s.savings_product_id - """); - - // Products that could not be paid, collected so the run can fail loudly at the end. - // - // Carrying on past one product is right — a mapping missing on one product must not stop - // every member of every other from being paid. Reporting the job as successful afterwards is - // not: the scheduler recorded 'success', the operator saw a green run, and the only trace - // that nobody had been paid was a log line and a row in m_savings_interest_posting_run that - // nothing reads. On a night when every product fails, that is a whole day of interest - // silently unpaid. - final List failures = new java.util.ArrayList<>(); - - for (final Map schedule : schedules) { - final long productId = ((Number) schedule.get("savings_product_id")).longValue(); - - // Whether anything is due is decided inside postForProduct, not here. - // - // This used to pre-check with its own COUNT over m_savings_daily_interest, and that - // deadlocked the run: accrueForDate above is @Transactional and has just written every - // ledger row, so its transaction still holds them; the COUNT then read those rows on - // this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a - // transaction of its own — sat waiting for a lock this thread was holding and would only - // release after postForProduct returned. Each waited on the other and the job hung - // silently, with nothing in the log and the flag left saying it was still running. - // - // postForProduct already selects what is due and returns zero when there is nothing, so - // the pre-check bought nothing but the deadlock. Its selection is by posting_date rather - // than by the run date, which is what makes a missed posting date catch up on a later - // run instead of being skipped for good. - try { - final BigDecimal paid = postingService.postForProduct(productId, businessDate); - if (paid.signum() == 0) { - updateSchedule(productId, businessDate, null); - continue; - } - updateSchedule(productId, businessDate, "COMPLETED"); - log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate); - } catch (final RuntimeException e) { - // The product rolled back on its own; record why and let the others run. - log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e); - // An UPDATE, so it writes nothing when the product threw before openRun() inserted - // the row — which is exactly what a product with no GL mapping does. The failure is - // therefore collected in memory as well, so it cannot be lost with the breadcrumb. - jdbcTemplate.update(""" - UPDATE m_savings_interest_posting_run - SET status = 'FAILED', failure_reason = ? - WHERE savings_product_id = ? AND posting_date = ? - """, truncate(e.getMessage()), productId, businessDate); - updateSchedule(productId, businessDate, "FAILED"); - failures.add("product " + productId + ": " + e.getMessage()); - } + // Keeps each product's next posting date on the timetable in step with the ledger, which + // the posting loop used to do as a side effect of looking for work. + for (final Long productId : jdbcTemplate.queryForList( + "SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) { + updateSchedule(productId, businessDate, null); } - // Fails the job so the scheduler records it as failed and an operator is told. Every product - // that could be paid has already been paid and committed by this point — this reports what - // happened, it does not undo it. + // Fails the job so the scheduler records it as failed and an operator is told. Every report + // that could be paid has already been paid and committed by this point. if (!failures.isEmpty()) { throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for " - + failures.size() + " product(s): " + String.join("; ", failures)); + + failures.size() + " report(s): " + String.join("; ", failures)); } } + /** + * Posts every approved interest posting report dated on or before the date. + * + *

+ * Each report posts in a new transaction of its own. That is required, not a preference: this is + * called from the day close's after-commit callback, where the closing transaction has finished + * but is still bound to the thread, and joining it would write nothing that ever commits. It also + * keeps one product's failure from rolling back another's payment. A report that fails stays + * APPROVED with the reason recorded, and the nightly run tries it again. + * + * @return one message per report that could not be posted + */ + public List postApprovedReports(final LocalDate upToDate) { + return postReports(reportService.approvedReportIds(upToDate)); + } + + /** + * Posts the given approved reports, each in a new transaction of its own (see + * {@link #postApprovedReports}). Used by the day close for one branch's reports. + * + * @return one message per report that could not be posted + */ + public List postReports(final List reportIds) { + final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager); + newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + final List failures = new java.util.ArrayList<>(); + for (final Long reportId : reportIds) { + final Map report = jdbcTemplate.queryForMap( + "SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId); + final long productId = ((Number) report.get("savings_product_id")).longValue(); + final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + try { + final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId)); + newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED")); + log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate); + } catch (final RuntimeException e) { + log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e); + newTransaction.executeWithoutResult(status -> { + reportService.recordPostingFailure(reportId, e.getMessage()); + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_run + SET status = 'FAILED', failure_reason = ? + WHERE report_id = ? + """, truncate(e.getMessage()), reportId); + updateSchedule(productId, postingDate, "FAILED"); + }); + failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage()); + } + } + return failures; + } + /** * Releases this job's own currently_running flag if an earlier run left it set. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index dbd2fd400..22332b375 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -243,7 +243,7 @@ public class SavingsDailyInterestService { carriedInterest = backfill.cumulativeInterest(); } else { final Map prev = previous.get(0); - openingBalance = (BigDecimal) prev.get("closing_balance"); + final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate(); final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day")); final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate(); // A cycle is finished either because it was paid out or because its end date has gone @@ -251,11 +251,24 @@ public class SavingsDailyInterestService { // run was missed, or the account was accrued again after a break - and treating it as // unfinished would leave the row in a cycle that ended before the day it records. if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) { - carriedInterest = BigDecimal.ZERO; cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1; cycleStart = prevCycleEnd.plusDays(1); cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod, financialYearBeginningMonth); + // The new cycle may already be days old: the business date moved on by more than a + // day, or nights were missed. Those days earned too. Starting the cycle at zero and + // counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of + // October. They are replayed from the account's transactions, as a first row is, and + // the opening balance comes from the same replay so money moved in the gap counts. + if (cycleStart.isBefore(businessDate)) { + final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear, + minBalance, averageDailyBalanceOf(calcType)); + openingBalance = gap.closingBalance(); + carriedInterest = gap.cumulativeInterest(); + } else { + openingBalance = (BigDecimal) prev.get("closing_balance"); + carriedInterest = BigDecimal.ZERO; + } } else { cycleNumber = ((Number) prev.get("cycle_number")).intValue(); cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate(); @@ -274,12 +287,15 @@ public class SavingsDailyInterestService { // So the gap is replayed from the account's own transactions, the same way a first // row is built, and the result stands in for the running total the ledger would be // holding had it run every night. - final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate(); if (prevBalanceDate.plusDays(1).isBefore(businessDate)) { final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear, minBalance, averageDailyBalanceOf(calcType)); + // The replay's balance, not the stale row's: deposits made during the gap were + // otherwise left out of every balance from here on. + openingBalance = gap.closingBalance(); carriedInterest = gap.cumulativeInterest(); } else { + openingBalance = (BigDecimal) prev.get("closing_balance"); carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest"); } } @@ -506,7 +522,11 @@ public class SavingsDailyInterestService { final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) { if (!cycleStart.isBefore(upTo)) { - return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO); + // No day of the cycle to replay — it opens today — but the money held going into it is + // still there. Returning a zero balance made every re-accrual on a cycle's first day (a + // deposit, or the noon report, on the 1st) open the account at nil: an account holding + // 11,503.15 then earned only on what was deposited that day. + return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO); } // Everything on or before the eve of the ledger's first day, keyed by day. The balance before diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java new file mode 100644 index 000000000..e3a7999c5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java @@ -0,0 +1,578 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * The interest posting report: what each savings account of one branch will be paid on its product's + * posting date, put in front of that branch before any money moves. + * + *

+ * One report per branch and product. On a posting date the noon job generates one for every branch + * holding accounts with interest due; a user of that branch may generate or regenerate it at any time + * — the previous report is superseded, not overwritten — and approves it. The branch's approved + * reports are posted when that branch closes its day, and until they are approved it cannot close. + * The head office closes last, once every branch open that day has closed. + * + *

+ * Only the branch acts on its own reports. The single exception is a branch that did not open that + * day: the head office may then generate and approve its report on its behalf, and posts it at the + * head office's own close. Every entry is still raised in the branch the customers belong to. + * + *

+ * The figures are the daily ledger's own: each account's running interest for the cycle, rounded to + * two decimals, with the paise the rounding loses given to the largest balance so the report's total + * is the branch's pooled interest exactly. + */ +@Service +@Slf4j +public class SavingsInterestPostingReportService { + + public static final String GENERATED = "GENERATED"; + public static final String APPROVED = "APPROVED"; + public static final String POSTED = "POSTED"; + public static final String SUPERSEDED = "SUPERSEDED"; + + private static final int MONEY_SCALE = 2; + private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + /** m_savings_account.status_enum for an active account. */ + private static final int ACTIVE = 300; + + /** A product with interest due in one branch. */ + private record DueKey(Long productId, Long officeId) {} + + private final JdbcTemplate jdbcTemplate; + private final SavingsDailyInterestService accrualService; + private final SavingsPooledInterestPostingService postingService; + private final TransactionTemplate transactionTemplate; + + public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService, + final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.accrualService = accrualService; + this.postingService = postingService; + // Always a transaction of its own. The noon job runs inside the batch step's transaction, and + // generating in it would read the accrual through that uncommitted transaction and hold its + // row locks for the whole run; a report committed on its own is also visible to the screen at once. + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + // ── generation ─────────────────────────────────────────────────────────────────────────────── + + /** + * The noon job: brings the ledger up to the business date, then generates a report for every + * branch and product with interest due that does not already have one. + * + *

+ * Each report is generated in its own transaction, so one that cannot be — a GL mapping missing, + * say — does not stop the others. The failures are raised together at the end so the job is + * recorded as failed. + * + * @return the ids of the reports generated + */ + public List generateDue(final LocalDate postingDate) { + // So each account's figure includes the posting date itself, on the balance it holds now. + transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate)); + + final List generated = new ArrayList<>(); + final List failures = new ArrayList<>(); + for (final DueKey due : dueKeys(postingDate, null)) { + if (activeReportId(due.productId(), due.officeId(), postingDate) != null) { + continue; + } + try { + generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null))); + } catch (final RuntimeException e) { + log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(), + due.officeId(), postingDate, e); + failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage()); + } + } + if (!failures.isEmpty()) { + throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for " + + failures.size() + " report(s): " + String.join("; ", failures)); + } + return generated; + } + + /** + * Generates the report afresh for one branch and product, on the user's request. + * + *

+ * Allowed at any time of day; only the noon job waits for noon. Not for a date after the business + * date, whose interest has not been earned yet. A posted report cannot be regenerated: its money + * has moved. An approved one can, and has to be approved again. + */ + public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date", + "The report for " + postingDate + " cannot be generated before that date."); + } + authorize(user, officeId, businessDate); + transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate)); + return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId())); + } + + /** + * Approves a generated report. A user of the report's branch approves it; the head office may + * approve it only for a branch that did not open that day, and the report records that it did. + */ + public void approve(final Long reportId, final AppUser user) { + final List> found = jdbcTemplate + .queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found", + "Interest posting report " + reportId + " does not exist."); + } + final Long officeId = ((Number) found.get(0).get("office_id")).longValue(); + final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate()); + final int approved = jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_report + SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ? + WHERE id = ? AND status = 'GENERATED' + """, user.getId(), onBehalf, reportId); + if (approved == 0) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated", + "Only a report in GENERATED status can be approved. Reload the report — it may have been approved, " + + "regenerated or posted already."); + } + } + + // ── day close ──────────────────────────────────────────────────────────────────────────────── + + /** + * What stops one branch from closing the day: a product with interest due in the branch and no + * approved (or posted) report for the date, and any older report of the branch still awaiting + * approval — one left from a day the branch did not open. A report that was never generated + * blocks the close too. + */ + public List reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) { + final Set blocking = new LinkedHashSet<>(); + for (final DueKey due : dueKeys(businessDate, officeId)) { + final Long settled = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_interest_posting_report + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED') + """, Long.class, due.productId(), officeId, businessDate); + if (settled == null || settled == 0) { + blocking.add(productName(due.productId()) + " (" + businessDate + ")"); + } + } + for (final Map pending : jdbcTemplate.queryForList(""" + SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ? + ORDER BY r.posting_date + """, officeId, businessDate)) { + blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")"); + } + return new ArrayList<>(blocking); + } + + /** The branch's approved reports, posted when it closes the day. */ + public List approvedReportIds(final Long officeId, final LocalDate upToDate) { + return jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_interest_posting_report + WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ? + ORDER BY posting_date, savings_product_id + """, Long.class, officeId, upToDate); + } + + /** + * Approved reports of branches that did not open on the date — what the head office posts at its + * own close, the branches having no close of their own that day. + */ + public List approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) { + return jdbcTemplate.queryForList(""" + SELECT r.id FROM m_savings_interest_posting_report r + WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ? + AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?) + ORDER BY r.posting_date, r.office_id, r.savings_product_id + """, Long.class, businessDate, headOfficeId, businessDate); + } + + /** Every approved report still waiting, for the nightly safety net. */ + public List approvedReportIds(final LocalDate upToDate) { + return jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_interest_posting_report + WHERE status = 'APPROVED' AND posting_date <= ? + ORDER BY posting_date, office_id, savings_product_id + """, Long.class, upToDate); + } + + /** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */ + public void recordPostingFailure(final Long reportId, final String reason) { + jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?", + reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId); + } + + // ── reading ────────────────────────────────────────────────────────────────────────────────── + + /** + * Reports for the screen. A branch user sees only their branch; a head office user sees every + * branch, so it can act for one that did not open. canAct says whether this user may regenerate + * or approve the report. + */ + public List> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId, + final AppUser user) { + final Long userOffice = officeOf(user); + final boolean headOffice = isHeadOffice(userOffice); + final Long branch = headOffice ? officeId : userOffice; + + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId", + o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest", + r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", + gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy", + r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason" + FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser gu ON gu.id = r.generated_by + LEFT JOIN m_appuser au ON au.id = r.approved_by + WHERE 1 = 1 + """); + final List params = new ArrayList<>(); + if (postingDate != null) { + sql.append(" AND r.posting_date = ?"); + params.add(postingDate); + } + if (status != null && !status.isBlank()) { + sql.append(" AND r.status = ?"); + params.add(status.trim().toUpperCase()); + } else { + sql.append(" AND r.status <> 'SUPERSEDED'"); + } + if (productId != null) { + sql.append(" AND r.savings_product_id = ?"); + params.add(productId); + } + if (branch != null) { + sql.append(" AND r.office_id = ?"); + params.add(branch); + } + sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC"); + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final List> rows = new ArrayList<>(); + for (final Map row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) { + final Map out = new LinkedHashMap<>(row); + final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue(); + out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate)); + rows.add(out); + } + return rows; + } + + public Map detail(final Long reportId) { + final List> found = jdbcTemplate.queryForList(""" + SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId", + o.name AS "officeName", r.posting_date AS "postingDate", + r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount", + r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy", + r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf", + r.posted_at AS "postedAt", r.failure_reason AS "failureReason", + pg.gl_code AS "payableGlCode", pg.name AS "payableGlName", + dg.gl_code AS "paidGlCode", dg.name AS "paidGlName", + eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName" + FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser gu ON gu.id = r.generated_by + LEFT JOIN m_appuser au ON au.id = r.approved_by + LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id + LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id + LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2 + AND em.financial_account_type = 3 AND em.payment_type IS NULL + LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id + WHERE r.id = ? + """, reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found", + "Interest posting report " + reportId + " does not exist."); + } + final Map report = new LinkedHashMap<>(found.get(0)); + report.put("lines", jdbcTemplate.queryForList(""" + SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName", + l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore", + l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId" + FROM m_savings_interest_posting_report_line l + WHERE l.report_id = ? + ORDER BY l.account_no + """, reportId)); + return report; + } + + /** + * Branch and product interest for the head office: per report, how many customers and how much + * interest, and where it stands. Superseded reports are left out — each line is the report that + * counts for that branch, product and date. + */ + public List> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) { + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName", + r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount", + r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy", + r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt" + FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser au ON au.id = r.approved_by + WHERE r.status <> 'SUPERSEDED' + """); + final List params = new ArrayList<>(); + if (fromDate != null) { + sql.append(" AND r.posting_date >= ?"); + params.add(fromDate); + } + if (toDate != null) { + sql.append(" AND r.posting_date <= ?"); + params.add(toDate); + } + if (productId != null) { + sql.append(" AND r.savings_product_id = ?"); + params.add(productId); + } + sql.append(" ORDER BY r.posting_date DESC, p.name, o.name"); + return jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + /** + * Branch and product pairs with interest due by the date that have no report yet — the noon job ran + * before they had anything due, or failed. Limited to what the user may act on. + */ + public List> dueWithoutReport(final LocalDate postingDate, final AppUser user) { + final List> out = new ArrayList<>(); + // A future date has nothing due yet, and offering to generate for it would label this cycle's + // part-earned interest with a date it has not reached. + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + return out; + } + final Long userOffice = officeOf(user); + for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) { + if (activeReportId(due.productId(), due.officeId(), postingDate) == null + && mayAct(userOffice, due.officeId(), businessDate)) { + final Map row = new LinkedHashMap<>(); + row.put("productId", due.productId()); + row.put("productName", productName(due.productId())); + row.put("officeId", due.officeId()); + row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId())); + row.put("postingDate", postingDate.toString()); + out.add(row); + } + } + return out; + } + + // ── who may act ────────────────────────────────────────────────────────────────────────────── + + public boolean isHeadOffice(final Long officeId) { + final List parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId); + return !parent.isEmpty() && parent.get(0) == null; + } + + /** Whether the branch opened its day on the date — the head office may act only for one that did not. */ + public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) { + final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + Long.class, officeId, businessDate); + return rows != null && rows > 0; + } + + private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + if (userOffice == null) { + return false; + } + if (userOffice.equals(reportOffice)) { + return true; + } + return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate); + } + + /** @return true where the head office is acting on a branch's behalf */ + private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) { + final Long userOffice = officeOf(user); + if (userOffice != null && userOffice.equals(reportOffice)) { + return false; + } + if (mayAct(userOffice, reportOffice, businessDate)) { + return true; + } + final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice); + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", + "Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day " + + branch + " has not opened."); + } + + private static Long officeOf(final AppUser user) { + return user == null || user.getOffice() == null ? null : user.getOffice().getId(); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + /** + * Branch and product pairs with interest due on or before the date: a savings product on the + * daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in + * accounts belonging to that branch. + */ + private List dueKeys(final LocalDate postingDate, final Long officeId) { + final StringBuilder sql = new StringBuilder(""" + SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id + FROM m_savings_daily_interest d + JOIN m_savings_product p ON p.id = d.savings_product_id + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true + WHERE p.deposit_type_enum = 100 + AND sa.status_enum = ? + AND d.posting_date <= ? + AND d.savings_transaction_id IS NULL + AND d.cycle_cumulative_interest > 0 + AND COALESCE(c.office_id, g.office_id) IS NOT NULL + """); + final List params = new ArrayList<>(List.of(ACTIVE, postingDate)); + if (officeId != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(officeId); + } + sql.append(" ORDER BY 2, 1"); + return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream() + .map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue())) + .toList(); + } + + private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) { + final List ids = jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_interest_posting_report + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + """, Long.class, productId, officeId, postingDate); + return ids.isEmpty() ? null : ids.get(0); + } + + private String productName(final Long productId) { + return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId); + } + + /** Runs inside the caller's transaction. */ + private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) { + final List> previous = jdbcTemplate.queryForList(""" + SELECT id, status FROM m_savings_interest_posting_report + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + FOR UPDATE + """, productId, officeId, postingDate); + for (final Map report : previous) { + if (POSTED.equals(report.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted", + "Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be" + + " regenerated."); + } + } + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED' + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED') + """, productId, officeId, postingDate); + + final List> due = jdbcTemplate.queryForList(""" + SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, d.cycle_cumulative_interest AS gross_interest, + sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE d.savings_product_id = ? AND d.posting_date <= ? + AND d.savings_transaction_id IS NULL + AND d.cycle_cumulative_interest > 0 + AND sa.status_enum = ? + AND COALESCE(c.office_id, g.office_id) = ? + ORDER BY d.savings_account_id + """, productId, postingDate, ACTIVE, officeId); + if (due.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due", + "No interest is due for this product in this branch on " + postingDate + "."); + } + + // The GL accounts as the product maps them now; a product missing one is refused here, before + // anybody approves figures that could never be posted. + final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId); + + // Round each customer's share, then make the rounded shares add up to the rounded pool: the + // residual goes to the largest balance, where a paisa is proportionally smallest. + BigDecimal grossTotal = BigDecimal.ZERO; + BigDecimal roundedTotal = BigDecimal.ZERO; + for (final Map row : due) { + final BigDecimal gross = (BigDecimal) row.get("gross_interest"); + final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING); + row.put("rounded_interest", rounded); + grossTotal = grossTotal.add(gross); + roundedTotal = roundedTotal.add(rounded); + } + final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal residual = pooled.subtract(roundedTotal); + if (residual.signum() != 0) { + final Map largest = due.stream().max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))) + .orElseThrow(); + largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual)); + } + + final Long reportId = jdbcTemplate.queryForObject(""" + INSERT INTO m_savings_interest_posting_report + (savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id, + paid_gl_account_id, generation_mode, generated_by, generated_at) + VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId); + + for (final Map row : due) { + final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal interest = (BigDecimal) row.get("rounded_interest"); + jdbcTemplate.update(""" + INSERT INTO m_savings_interest_posting_report_line + (report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before, + balance_after, ledger_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest, + before, before.add(interest), row.get("ledger_id")); + } + + log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId, + postingDate, mode, pooled, due.size()); + return reportId; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index d2fc838bb..2082c2c46 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.dailyinterest; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; -import java.util.Comparator; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -29,6 +28,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional; * a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere * holds the figure that would say so. * - * This settles the debt the accrual has already recognised: the product's total moves out of the - * liability the product names as its Savings Interest Payable, into the account it is paid out of, - * and only then to the members: + * The product's total is raised as an expense and credited to the liability the product names as its + * Savings Interest Payable, moves from there into the clearing account it is paid out of, and only + * then to the members: * *
- *   Dr  Savings Interest Payable   (liability, from the product)
- *   Cr  Savings Interest Paid                                         on its way out
+ *   Dr  Interest On Savings        (expense, from the product)       cash-accounted products only
+ *   Cr  Savings Interest Payable   (liability, from the product)
+ *
+ *   Dr  Savings Interest Payable
+ *   Cr  Savings Interest Paid      (liability clearing)               on its way out
  *
  *   Dr  Savings Interest Paid      (per member)
  *   Cr  Savings Control            (per member, from the product)     in the member's hands
  * 
* - * The interest expense is not raised here. It is recognised as the interest accrues, day by day, and - * this is the settlement of what that recognition already booked - raising the expense again at - * payout would put the same interest on the P&L twice. + * The payable and paid accounts each close the posting at nil, so the only lasting effect is the + * expense against the members' balances, and a liability report reading the payable is unaffected. + * The first pair is skipped for an accrual-accounted product, whose accruals already raised it. * * Every account named above is read from the product's own GL configuration; none is written into * this class. The payable comes from the Interest Payable row on the product's Accounting tab, and @@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService { * Accounting tab, which is where a bank picks the account interest is owed from. */ private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17; + /** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */ + private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3; + /** m_savings_product.accounting_type for cash-based accounting. */ + private static final int ACCOUNTING_TYPE_CASH = 2; private final JdbcTemplate jdbcTemplate; + /** The payable and paid accounts a product's interest passes through. */ + public record PostingGlAccounts(Long payableGl, Long paidGl) {} + /** - * Posts one product's interest for one posting date. - * - * @return the amount distributed, or zero if there was nothing to do + * The payable and paid accounts, as the product maps them now. * *

- * REQUIRED, not REQUIRES_NEW. + * The product's Accounting tab is the authority: the standard Interest Payable row first, then + * the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls + * back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and + * leaves the two-legged posting a product without these mappings has always had. + */ + public PostingGlAccounts resolvePostingGlAccounts(final long productId) { + // All three are required. Falling back to Savings Control when one was left blank posted the + // interest straight from expense to the member with no payable or paid leg at all, and nobody + // could tell from the entries that the product had simply lost its mappings. + mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS); + final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE), + namedGlAccount(productId, MAPPING_INTEREST_PAYABLE)); + final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID); + if (payableGl == null || paidGl == null) { + final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, + productId); + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '" + + productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid") + + " GL mapped. Set it under the product's Accounting and generate the report again."); + } + return new PostingGlAccounts(payableGl, paidGl); + } + + /** + * Posts one approved interest posting report, paying each account exactly what was approved. * - * One product's failure must not roll back another's payment, and REQUIRED gives that already: - * the orchestrator calls this outside any transaction of its own, so each product opens and - * commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is - * running and waits for locks that transaction may hold. The accrual step immediately before - * writes every account's ledger row, and this method reads and updates those same rows: with - * REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding, - * and the job hung with nothing in the log and its currently_running flag left set. + *

+ * A report belongs to one branch and one product, and every leg it raises is in that branch, so + * the branch's interest cost sits in its own P&L and its payable and paid accounts close at nil + * in its own books: + * + *

+     *   Dr  Interest On Savings  (branch total)  →  Cr  Savings Interest Payable
+     *   Dr  Savings Interest Payable              →  Cr  Savings Interest Paid
+     *   Dr  Savings Interest Paid  (once)         →  Cr  each customer of the branch
+     * 
+ * + *

+ * Nothing is recomputed. The report's lines are the authority: interest that accrued after the + * report was generated is not added, because the approver signed off on these figures and not on + * others. The payable and paid accounts are those recorded on the report when it was generated. + * + *

+ * Everything is dated the day it is actually posted — the business date — not the cycle's end: a + * branch that was shut on its posting date posts when it next closes, into a day that is still + * open, and the narration names the cycle the interest is for. + * + *

+ * The whole report settles in one transaction, so a failure leaves nobody half paid and the report + * still APPROVED for the next attempt. + * + * @return the amount distributed */ @Transactional - public BigDecimal postForProduct(final long productId, final LocalDate postingDate) { - final Long completed = jdbcTemplate.queryForObject(""" - SELECT COUNT(*) FROM m_savings_interest_posting_run - WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED' - """, Long.class, productId, postingDate); - if (completed != null && completed > 0) { - log.info("Savings interest posting: product {} already completed for {}", productId, postingDate); - return BigDecimal.ZERO; + public BigDecimal postReport(final long reportId) { + final List> reports = jdbcTemplate.queryForList(""" + SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id, + paid_gl_account_id + FROM m_savings_interest_posting_report + WHERE id = ? + FOR UPDATE + """, reportId); + if (reports.isEmpty()) { + throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist"); + } + final Map report = reports.get(0); + if (!"APPROVED".equals(report.get("status"))) { + throw new IllegalStateException( + "Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED"); + } + final long productId = ((Number) report.get("savings_product_id")).longValue(); + final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + final LocalDate postedOn = DateUtils.getBusinessLocalDate(); + final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING); + + final List> lines = jdbcTemplate.queryForList(""" + SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id, + sa.currency_code + FROM m_savings_interest_posting_report_line l + JOIN m_savings_account sa ON sa.id = l.savings_account_id + WHERE l.report_id = ? + ORDER BY l.savings_account_id + """, reportId); + if (lines.isEmpty()) { + throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay"); + } + final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue() + : ((Number) lines.get(0).get("office_id")).longValue(); + + final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size()); + + final PostingGlAccounts current = resolvePostingGlAccounts(productId); + final Long payableGl = report.get("payable_gl_account_id") != null + ? ((Number) report.get("payable_gl_account_id")).longValue() + : current.payableGl(); + final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue() + : current.paidGl(); + + final String currency = (String) lines.get(0).get("currency_code"); + // One per report, and deterministic: a retried posting carries the same reference as the legs a + // failed attempt rolled back, and two branches posting the same product never share one. + final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId); + final String narration = "Savings interest for cycle ending " + cycleDate; + + // Stage 0: the interest cost is raised and the total credited to the payable. Nothing else + // credits it on a cash-accounted product — the daily accrual writes no journal entry. An + // accrual-accounted product has already done this through its accrual postings. + if (isCashAccounted(productId)) { + final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS); + journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration); + journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration); } - // Every account whose posting date has arrived or passed and has not yet been paid. - // - // Read by posting_date rather than by the run date: the ledger keeps one row per account and - // overwrites it daily, so on the day after a quarter end the row saying "this was a posting - // day" no longer exists. Matching on the run date alone meant a quarter end the job did not - // run on was never paid at all — the cycle simply rolled over with the interest carried into - // it. A posting that is owed stays owed until it is made, which is how an overnight batch has - // to behave when a night is missed. - // - // Anything already carrying a posting transaction is skipped, so a resumed run cannot pay - // somebody a second time. - // - // A savings account has no office of its own: it belongs to a client, or to a group when - // there is no client, and the office is theirs. The journal entries have to be raised - // against that office or they land in the wrong branch's books. - final List> due = jdbcTemplate.queryForList(""" - SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, - d.cycle_cumulative_interest AS gross_interest, - COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code, - sa.currency_digits - FROM m_savings_daily_interest d - JOIN m_savings_account sa ON sa.id = d.savings_account_id - LEFT JOIN m_client c ON c.id = sa.client_id - LEFT JOIN m_group g ON g.id = sa.group_id - WHERE d.savings_product_id = ? AND d.posting_date <= ? - AND d.savings_transaction_id IS NULL - AND d.cycle_cumulative_interest > 0 - AND COALESCE(c.office_id, g.office_id) IS NOT NULL - ORDER BY d.savings_account_id - """, productId, postingDate); - - if (due.isEmpty()) { - log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate); - return BigDecimal.ZERO; - } - - final long runId = openRun(productId, postingDate, due.size()); - - // Round each member's share, then make the rounded shares add up. The residual is the - // difference between what the pool holds and what the members would otherwise receive. - BigDecimal grossTotal = BigDecimal.ZERO; - BigDecimal roundedTotal = BigDecimal.ZERO; - for (final Map row : due) { - final BigDecimal gross = (BigDecimal) row.get("gross_interest"); - final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING); - row.put("rounded_interest", rounded); - grossTotal = grossTotal.add(gross); - roundedTotal = roundedTotal.add(rounded); - } - final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); - final BigDecimal residual = pooled.subtract(roundedTotal); - - if (residual.compareTo(BigDecimal.ZERO) != 0) { - // To the largest balance: the account where a paisa is proportionally smallest. - final Map largest = due.stream() - .max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow(); - largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual)); - log.info("Savings interest posting: residual {} assigned to account {}", residual, - largest.get("savings_account_id")); - } - - // The three accounts the bill passes through on its way to the members. Every one is read - // from the product's own configuration, so a bank on its own chart of accounts is posted to - // the accounts it chose. No GL code is named in this class. - // - // The product's Accounting tab is the authority, and it is consulted first for all three: - // Interest On Savings for the expense, and the standard Interest Payable row for the - // payable. Those are the rows a bank actually fills in on the product screen. - // - // The reserved names in the product's other GL mappings are consulted only after that, as a - // fallback for a product configured before Interest Payable was mapped, and for the paid leg, - // which has no standard row of its own. - // - // Where a product sets none of them the bill still has somewhere to go: the payable falls - // back to the product's Savings Control (interest owed is a liability either way), and the - // paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged - // posting a product without these mappings has always had. - final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE), - namedGlAccount(productId, MAPPING_INTEREST_PAYABLE), - mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL)); - final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl); - - final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); - final String currency = (String) due.get(0).get("currency_code"); - // Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted, - // and the resumed legs have to carry the same reference as the legs already posted, or one - // interest posting ends up split across two transaction ids. - final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate, - productId); - - // Stage 1: the debt the accrual has been building moves out of the payable and into the - // account the payout is made from, so what is still merely owed and what is on its way to - // the members are two separate balances rather than one. - // - // The interest expense itself is not raised here. It is recognised as the interest accrues, - // day by day, and posting is the settlement of that recognised debt — raising the expense - // again at payout would count the same interest twice on the P&L. - // - // Skipped where the product names no separate paid account: both legs then resolve to the - // payable and this would debit and credit one account for the same amount, saying nothing. - final boolean payableIsPaid = payableGl.equals(paidGl); - final boolean stage1Applies = !payableIsPaid; + // Stage 1: the total moves out of the payable and into the account the payout is made from. + // Skipped where both are the same account, which would debit and credit it for nothing. + final boolean stage1Applies = !payableGl.equals(paidGl); if (stage1Applies) { - journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null); + journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration); + journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration); } - - // Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2 - // draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw - // from there instead — debiting the paid account would take the money out of an account - // nothing put it into, leaving the payable standing as a debt that was in fact settled. final Long payoutFromGl = stage1Applies ? paidGl : payableGl; - // Stage 2: out to the members, one at a time. + // Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg + // carries the report's one transaction id, and every credit names its account and interest + // transaction, so who was paid what reads back from the journal alone. + journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration); BigDecimal distributed = BigDecimal.ZERO; - for (final Map row : due) { - final BigDecimal amount = (BigDecimal) row.get("rounded_interest"); - if (amount.compareTo(BigDecimal.ZERO) <= 0) { - continue; - } - final long accountId = ((Number) row.get("savings_account_id")).longValue(); - final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount); - // The member's own branch, which need not be the branch the pool was raised in. - final long accountOfficeId = ((Number) row.get("office_id")).longValue(); - - final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, - newBalance); - - journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference, - accountId); - creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, - accountId, transactionId); - - // closing_balance is raised here because the interest transaction has just been written - // and the accrual for this day has already run: nothing else will fold it in before the - // next day reads this row as its opening. - jdbcTemplate.update(""" - UPDATE m_savings_daily_interest - SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, - closing_balance = closing_balance + ? - WHERE id = ? - """, amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue()); - - // Both interest columns move together, and that is not a duplicate. - // - // total_interest_earned_derived is the interest the account has earned to date; - // total_interest_posted_derived is how much of that has been paid into the balance. - // Screens and validate()'s own check read the unposted interest as earned - posted, so - // raising only `posted` drives that difference negative: an account paid 26.52 with - // `earned` still at zero reports its unposted interest as -26.52, and the account screen - // showed exactly that. The accrual writes pending interest to the ledger and never to - // `earned`, so this posting is the only place `earned` is realised. - jdbcTemplate.update(""" - UPDATE m_savings_account - SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, - total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?, - total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ? - WHERE id = ? - """, amount, amount, amount, accountId); - - distributed = distributed.add(amount); + for (final Map line : lines) { + distributed = distributed + .add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId)); } - // The whole point of the two control accounts: if this does not hold, nothing commits. + // If the credits do not add up to the debit, nothing commits. if (pooled.compareTo(distributed) != 0) { throw new IllegalStateException(String.format( - "Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s", - productId, postingDate, pooled, distributed)); + "Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed)); } + return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size()); + } + + /** + * One customer's credit: the interest transaction, the Savings Control credit naming the account, + * and the account, ledger and report line brought up to date. The branch debit is raised by the + * caller. + * + * @return the amount credited, zero for a line with nothing to pay + */ + private BigDecimal payMember(final Map line, final long productId, final long officeId, + final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference, + final String narration, final long runId) { + final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + final long accountId = ((Number) line.get("savings_account_id")).longValue(); + + // A resumed or duplicated attempt must not pay anybody twice. The line itself records the + // payment, so this holds even where one account has two cycles posted on the same day. + if (line.get("savings_transaction_id") != null) { + throw new IllegalStateException("Savings account " + accountId + " was already paid by this report"); + } + + // The balance as it stands now, not as the report showed it: money moved since the report + // was generated, and the transaction's running balance has to follow the account. + final BigDecimal currentBalance = jdbcTemplate.queryForObject( + "SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId); + final BigDecimal newBalance = currentBalance.add(amount); + + final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance); + + final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL); + journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration); + + // Settles the ledger row where it still holds the cycle the report was drawn from. Where the + // nightly run has already moved it into the next cycle — a branch that was shut on its posting + // date and posts days later — that row is the new cycle's and must not be marked paid; it only + // takes the money, because the next day's accrual opens from its closing balance. + final int settled = jdbcTemplate.update(""" + UPDATE m_savings_daily_interest + SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, + closing_balance = closing_balance + ? + WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ? + """, amount, transactionId, runId, amount, accountId, cycleDate); + if (settled == 0) { + jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?", + amount, accountId); + } + + // Earned is only ever raised to what has now been posted, never added to: the engine may + // already have counted this interest as earned when the account was last recalculated, + // and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the + // unposted interest as earned - posted, so earned must not fall below posted either. + jdbcTemplate.update(""" + UPDATE m_savings_account + SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, + total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?, + total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0), + COALESCE(total_interest_posted_derived, 0) + ?) + WHERE id = ? + """, amount, amount, amount, accountId); + + jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?", + transactionId, ((Number) line.get("line_id")).longValue()); + + return amount; + } + + private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled, + final BigDecimal distributed, final long runId, final int accountCount) { jdbcTemplate.update(""" UPDATE m_savings_interest_posting_run - SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?, + SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0, status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP WHERE id = ? - """, pooled, distributed, residual, runId); + """, pooled, distributed, runId); + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_report + SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL + WHERE id = ? + """, runId, reportId); - log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId, - postingDate, distributed, due.size(), residual); + log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId, + cycleDate, distributed, accountCount); return distributed; } - private long openRun(final long productId, final LocalDate postingDate, final int accountCount) { + /** One run per report. A retry of the same report reuses its run. */ + private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate, + final int accountCount) { jdbcTemplate.update(""" INSERT INTO m_savings_interest_posting_run - (savings_product_id, posting_date, account_count, status) - VALUES (?, ?, ?, 'PENDING') - ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET + (report_id, savings_product_id, office_id, posting_date, account_count, status) + VALUES (?, ?, ?, ?, ?, 'PENDING') + ON CONFLICT (report_id) DO UPDATE SET account_count = EXCLUDED.account_count, status = 'PENDING', started_at = CURRENT_TIMESTAMP, failure_reason = NULL - """, productId, postingDate, accountCount); - return jdbcTemplate.queryForObject(""" - SELECT id FROM m_savings_interest_posting_run - WHERE savings_product_id = ? AND posting_date = ? - """, Long.class, productId, postingDate); + """, reportId, productId, officeId, cycleDate, accountCount); + return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId); } private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date, @@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService { """, Long.class, accountId, date, INTEREST_POSTING); } - /** The member's own leg: the product's Savings Control account, credited per account. */ - private void creditMemberSavingsControl(final long officeId, final String currency, final long productId, - final LocalDate date, final BigDecimal amount, final String reference, final long accountId, - final long transactionId) { - final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL); - journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId); - } - - private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, - final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) { - journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null); - } - /** * One journal entry leg. * @@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService { */ private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, - final Long transactionId) { + final Long transactionId, final String narration) { GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update(""" INSERT INTO acc_gl_journal_entry @@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService { created_on_utc, last_modified_on_utc, submitted_on_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) - """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, - debit ? "Savings interest posting" : "Savings interest posted", + """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(), date); } @@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService { } /** The first configured account of those given, in order of preference. */ + private boolean isCashAccounted(final long productId) { + final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?", + Integer.class, productId); + return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH; + } + private static Long firstNonNull(final Long... candidates) { for (final Long candidate : candidates) { if (candidate != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java new file mode 100644 index 000000000..070e2dbfb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java @@ -0,0 +1,191 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +/** + * The savings interest posting reports, per branch and product: list, detail, generate, approve, the + * day-close check, and the head office's branch-wise summary. + * + *

+ * Posting is not offered here. A branch's approved reports are posted when that branch closes the + * day, so there is exactly one way money leaves the payable. + */ +@Path("/v1/savingsinterestpostingreports") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class SavingsInterestPostingReportApiResource { + + private static final String RESOURCE = "SAVINGSINTERESTREPORT"; + + private final SavingsInterestPostingReportService reportService; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + /** A branch user sees their own branch; a head office user every branch, or one with officeId. */ + @GET + public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status, + @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList()); + } + + /** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */ + @GET + @Path("/due-without-report") + public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + return gson.toJson(reportService.dueWithoutReport(date, user)); + } + + /** + * What stops a branch closing the business day: its own reports due and not yet approved. The + * day-close screen reads it to warn before the close is attempted. + */ + @GET + @Path("/day-close-check") + public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) { + final AppUser user = context.authenticatedUser(); + final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date); + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final List blocking = reportService.reportsBlockingDayClose(officeId, businessDate); + final Map result = new LinkedHashMap<>(); + result.put("businessDate", businessDate.toString()); + result.put("canClose", blocking.isEmpty()); + result.put("pendingProducts", blocking); + return gson.toJson(result); + } + + /** The head office's view: per branch and product, customers paid and interest, over a date range. */ + @GET + @Path("/summary") + public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("productId") final Long productId) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + if (!reportService.isHeadOffice(user.getOffice().getId())) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only", + "The branch-wise interest summary is for the head office."); + } + return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList()); + } + + @GET + @Path("/{reportId}") + public String detail(@PathParam("reportId") final Long reportId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE); + final Map report = plain(reportService.detail(reportId)); + @SuppressWarnings("unchecked") + final List> lines = (List>) report.get("lines"); + report.put("lines", lines.stream().map(this::plain).toList()); + return gson.toJson(report); + } + + /** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + public String command(@QueryParam("command") final String command, final String body) { + if (!"regenerate".equalsIgnoreCase(command)) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command", + "Unsupported command: " + command); + } + final AppUser user = context.authenticatedUser(); + user.validateHasPermissionTo("REGENERATE_" + RESOURCE); + final var root = fromJsonHelper.parse(body); + final Long productId = fromJsonHelper.extractLongNamed("productId", root); + final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root); + final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root); + if (productId == null) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?"); + } + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + final Long reportId = reportService.regenerate(productId, officeId, date, user); + return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED)); + } + + /** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */ + @POST + @Path("/{reportId}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) { + if (!"approve".equalsIgnoreCase(command)) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command", + "Unsupported command: " + command); + } + final AppUser user = context.authenticatedUser(); + user.validateHasPermissionTo("APPROVE_" + RESOURCE); + reportService.approve(reportId, user); + return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED)); + } + + private static LocalDate parseDate(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return LocalDate.parse(raw.trim()); + } catch (final RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date", + "Dates are expected as yyyy-MM-dd, not " + raw); + } + } + + /** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */ + private Map plain(final Map row) { + final Map out = new LinkedHashMap<>(); + row.forEach((key, value) -> { + if (value instanceof java.sql.Date d) { + out.put(key, d.toLocalDate().toString()); + } else if (value instanceof Timestamp t) { + out.put(key, t.toLocalDateTime().toString()); + } else { + out.put(key, value); + } + }); + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java new file mode 100644 index 000000000..134bafe04 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.jobs; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class SavingsInterestPostingReportConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) { + return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository) + .tasklet(savingsInterestPostingReportTasklet, transactionManager).build(); + } + + @Bean + public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) { + return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository) + .start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer()) + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java new file mode 100644 index 000000000..287b00e6d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.jobs; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * Noon on the business date: generates the interest posting report for every savings product whose + * interest falls due, so it can be reviewed and approved before the branches close the day. + */ +@RequiredArgsConstructor +@Component +public class SavingsInterestPostingReportTasklet implements Tasklet { + + private final SavingsInterestPostingReportService reportService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + reportService.generateDue(DateUtils.getBusinessLocalDate()); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 008486db0..b6c0ce636 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -436,8 +436,14 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead public List retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize, final Integer status, final Long maxSavingsId) { LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1); + // A product on the daily-interest timetable is paid by the pooled posting instead + // (SavingsPooledInterestPostingService). Posting it here too would pay its members twice. + // FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging + // subquery so a page is never cut short by accounts that are then dropped. String sql = "select " + this.savingAccountMapperForInterestPosting.schema() - + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id "; + + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? " + + "and not exists (select 1 from m_savings_interest_posting_schedule s " + + "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id "; if (backdatedTxnsAllowedTill) { sql = sql + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index b6ac7ca48..020cdbd2f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 9b2f6e419..3d875a303 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -144,6 +144,10 @@ + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_I_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_III_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VI_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VII_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml new file mode 100644 index 000000000..9e5c2a7c5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml @@ -0,0 +1,908 @@ + + + + + + + + + + + + + ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage; + ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage + CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'ASSET', 'MEMO')); + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE role_group = 'FORM9' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L4_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L5_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A4_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A6_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml new file mode 100644 index 000000000..916a4bc4b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml @@ -0,0 +1,212 @@ + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_%' + + + + + -- 1(a) Paid up capital - Individuals. Both share accounts: in a co-operative bank the + -- linking share and the ordinary share together ARE the members' paid up capital. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L1_A_CAPITAL_INDIVIDUALS', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('300101', '300102'); + + -- 2(a) Statutory Reserves. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_A_STATUTORY_RESERVE', id, 100, now() FROM acc_gl_account + WHERE gl_code = '300902'; + + -- 2(c) Dividend Equalisation Fund. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_C_DIVIDEND_EQUALISATION', id, 100, now() FROM acc_gl_account + WHERE gl_code = '300901'; + + -- 2(e) Bad and Doubtful Debt Reserve. The NPA provision AND the standard-asset + -- provision, because the form's line is the whole reserve held against advances - not + -- the bad-debt account alone. On the filed return this was drawn from, mapping only one + -- of them would have understated the line by three quarters. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('500501', '500502'); + + -- 2(g) Other Funds and Reserves - every other fund the bank carries. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200905'; + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L4_%' + + + + + -- 4(a)(iv) Current deposits from individuals, firms and associations. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L4_A4_CURRENT_INDIVIDUALS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200102'; + + -- 4(b)(iv) Savings deposits, DEMAND share. + -- + -- Split 11/89 against 5(b)(iv), which is the split the filed return this was drawn from + -- carries. It is NOT the 20/80 Form 1 uses: that return charges its ratios on a + -- differently drawn base, and the two splits are settled separately with the bank's + -- auditors. The two shares here must always total 100, or the return reports less in + -- savings than the bank holds. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', id, 11, now() FROM acc_gl_account + WHERE gl_code = '200101'; + + -- 4(d) Other Demand Liabilities - everything owed on demand that is not a deposit. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L4_D_OTHER_DEMAND', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('200203', '200204', '200401', '200402', '200501', '200502', '200503', + '200504', '200505', '200506', '200901', '200902', '200903', '200904', + '898966'); + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L5_%' + + + + + -- 5(a)(iv) Fixed deposits from individuals, firms and associations. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_A4_FD_INDIVIDUALS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200103'; + + -- 5(b)(iv) Savings deposits, TIME share - the remainder of the account. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', id, 89, now() FROM acc_gl_account + WHERE gl_code = '200101'; + + -- 5(d) Cash certificates and recurring deposits. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_D_CASH_CERT_RD', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200201'; + + -- 5(e) Other Time Liabilities - interest accrued on deposits but not yet paid. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_E_OTHER_TIME', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('200202', '200301', '200302', '200303'); + + -- 9. Balance of Profit. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L9_BALANCE_OF_PROFIT', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('300903', 'BANKPROFIT01'); + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_A%' + + + + + -- 1. Cash in Hand - vault and teller balances. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A1_CASH_IN_HAND', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100101', '100102'); + + -- 2(a) Balance with the Reserve Bank of India. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A2_A_CUR_RBI', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100203'; + + -- 2(c) Balance with the Banking System - the commercial banks the bank keeps operating + -- accounts with. Co-operative banks have their own lines, which this chart has no + -- accounts for. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A2_C_CUR_BANKING_SYSTEM', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100201', '100202'); + + -- 6(f)(i) Short term advances for other purposes - cash credit, loans against deposits + -- and warehouse receipt finance. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A6_F_OTHER_SHORT', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100301', '100302', '100303'); + + -- 6(f)(ii) Medium term advances - personal loans. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A6_F_OTHER_MEDIUM', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100402'; + + -- 6(f)(iii) Long term advances - loans against property and house loans. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A6_F_OTHER_LONG', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100401', '100501'); + + -- 8. Interest Receivable. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A8_INTEREST_RECEIVABLE', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100601'; + + -- 11(i) Other Assets, tangible - advance tax and tax deducted at source recoverable. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A11_I_TANGIBLE_ASSETS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100602'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml new file mode 100644 index 000000000..5d2997caf --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE a.gl_code = 'DIVROUNDOFF01' + + + + + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account + WHERE gl_code = 'DIVROUNDOFF01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml new file mode 100644 index 000000000..1e42440c8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Generate Savings Interest Posting Report' + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_SAVINGSINTERESTREPORT', + 'REGENERATE_SAVINGSINTERESTREPORT', 'APPROVE_SAVINGSINTERESTREPORT') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT', + 'APPROVE_SAVINGSINTERESTREPORT') + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT', + 'APPROVE_SAVINGSINTERESTREPORT') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml new file mode 100644 index 000000000..7695a23d6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_savings_interest_posting_report r + SET office_id = (SELECT MIN(l.office_id) FROM m_savings_interest_posting_report_line l WHERE l.report_id = r.id) + WHERE r.office_id IS NULL + + + + + + + + + + + + + + + + + + + From 16c67f6701dd1953c1cf62353fe9eb6be73180ef Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 4 Oct 2026 09:12:17 +0530 Subject: [PATCH 31/39] feat: Fixed Deposit - premature close logic, deposit account closure type, transaction validation, domain service and read service updates --- .../savings/DepositAccountOnClosureType.java | 15 ++- .../savings/DepositsApiConstants.java | 3 + .../savings/service/SavingsEnumerations.java | 4 + ...epositAccountTransactionDataValidator.java | 22 ++++- .../DepositAccountDomainServiceJpa.java | 96 +++++++++++++++++++ .../savings/domain/FixedDepositAccount.java | 14 ++- ...eMatureCalculationPlatformServiceImpl.java | 14 ++- ...DepositAccountReadPlatformServiceImpl.java | 6 +- .../src/main/resources/application.properties | 4 +- 9 files changed, 167 insertions(+), 11 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java index c4939917b..03a4763dc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java @@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType { TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), // REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), // REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), // - PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); // + PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), // + TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); // private final Integer value; private final String code; @@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType { case 300 -> REINVEST_PRINCIPAL_AND_INTEREST; case 400 -> REINVEST_PRINCIPAL_ONLY; case 500 -> PARTIAL_REINVEST; + case 600 -> TRANSFER_TO_GL; default -> INVALID; }; } @@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType { return this.equals(PARTIAL_REINVEST); } + /** + * Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash. + * Only the fixed-deposit premature-closure handler implements it -- see + * DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it + * in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the + * terminal "pay the balance out in cash" branch. + */ + public boolean isTransferToGl() { + return this.equals(TRANSFER_TO_GL); + } + // TODO: do we really need this?!? public static Object[] integerValues() { return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray(); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index 2ca00fc8c..fd2b66e0d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -212,6 +212,9 @@ public final class DepositsApiConstants { public static final String onAccountClosureIdParamName = "onAccountClosureId"; public static final String transferDescriptionParamName = "transferDescription"; public static final String toSavingsAccountIdParamName = "toSavingsAccountId"; + // Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are + // credited straight to this GL instead of a savings account or cash. + public static final String toGlAccountIdParamName = "toGlAccountId"; public static final String reinvestAmountParamName = "reinvestAmount"; // Lets a maker waive the product's configured pre-closure penalty for one premature closure -- // see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java index 3ed32ff1c..6c4a4bdc1 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java @@ -801,6 +801,10 @@ public final class SavingsEnumerations { optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(), DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest"); break; + case TRANSFER_TO_GL: + optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(), + DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account"); + break; } return optionData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java index 2aeaa146d..a841f1b1b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java @@ -78,7 +78,8 @@ public class DepositAccountTransactionDataValidator { DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName, transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName, - DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName)); + DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName, + DepositsApiConstants.prematureClosureReasonIdParamName)); private static final Set DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList( DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, @@ -247,6 +248,25 @@ public class DepositAccountTransactionDataValidator { } else if (accountOnClosureType.isReinvest() && isPreMatureClose) { baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId) .failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close"); + } else if (accountOnClosureType.isTransferToGl()) { + // Only the FD premature-closure handler books the GL leg. Offering it anywhere else would + // fall through to that handler's terminal "withdraw the whole balance" case and silently pay + // the deposit out in cash, which is exactly what the partial.reinvest guard above prevents. + if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) { + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode( + "transfer.to.gl.not.allowed", + "Transfer to a GL account is only supported for fixed-deposit premature closure"); + } + final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element); + baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId) + .cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode()); + } else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) { + // Premature closure proceeds have to be traceable into a savings account, so the cash payout + // is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl) + // and is refused here as well -- otherwise a direct API call could still take the cash route. + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode( + "withdraw.deposit.not.allowed", + "Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account"); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 6e4102a71..80ab253d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -25,6 +25,8 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavin import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; @@ -38,6 +40,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants; import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat; import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; @@ -48,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.account.PortfolioAccountType; import org.apache.fineract.portfolio.account.data.AccountTransferDTO; import org.apache.fineract.portfolio.account.domain.AccountTransferType; @@ -76,6 +80,7 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData; import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -96,6 +101,8 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository; private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository; private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -846,6 +853,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck); this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); + } else if (closureType.isTransferToGl()) { + final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName); + final BigDecimal proceeds = account.getAccountBalance(); + // Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer + // account rather than cash/vault -- nothing is paid over the counter here. The offset below then + // moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the + // net posting as Dr Savings Control / Cr chosen GL. + final boolean isInternalTransfer = true; + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false, + isRegularTransaction, isInternalTransfer); + savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); + postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate, + command.stringValueOfParameterNamed(transferDescriptionParamName)); } else { final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); @@ -863,6 +884,81 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi return savingsTransactionId; } + /** + * Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked: + * Dr the Liability Transfer account / Cr that GL account. + *

+ * The withdrawal booked immediately before this was declared an account transfer, so the accounting engine + * has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair + * netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real + * createJournalEntry command rather than written straight to the ledger, so it reverses, reports and + * reconciles exactly like any other manual entry. + */ + private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount, + final LocalDate closedDate, final String description) { + + final Long suspenseGlAccountId = liabilityTransferGlAccountId(); + + final JsonObject debitEntry = new JsonObject(); + debitEntry.addProperty("glAccountId", suspenseGlAccountId); + debitEntry.addProperty("amount", amount); + final JsonArray debits = new JsonArray(); + debits.add(debitEntry); + + final JsonObject creditEntry = new JsonObject(); + creditEntry.addProperty("glAccountId", toGlAccountId); + creditEntry.addProperty("amount", amount); + final JsonArray credits = new JsonArray(); + credits.add(creditEntry); + + final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber()); + if (description != null && !description.isBlank()) { + comments.append(" -- ").append(description); + } + + final String dateFormat = "dd MMMM yyyy"; + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", account.officeId()); + payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH))); + payload.addProperty("dateFormat", dateFormat); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", account.getCurrency().getCode()); + payload.addProperty("comments", comments.toString()); + payload.add("debits", debits); + payload.add("credits", credits); + + final String json = payload.toString(); + final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null, + null, null, null, null, null, null, null, null, null, null, null, null, null); + this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand); + } + + /** + * The GL account the withdrawal above credited, and therefore the exact account this entry has to debit + * to clear it. + *

+ * It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In + * Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal + * as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping + * instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry + * collapse onto whichever account that mapping names. + *

+ * Refusing outright when it is unmapped is deliberate: silently picking another account would put the + * closure's money somewhere nobody chose. + */ + private Long liabilityTransferGlAccountId() { + final List glAccountIds = this.jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC", + Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue()); + if (glAccountIds.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped", + "No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure" + + " proceeds cannot be routed to a GL account. Map it first.", + AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue()); + } + return glAccountIds.get(0); + } + /** * A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close * (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 788ade745..b43d758ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -946,13 +946,21 @@ public class FixedDepositAccount extends SavingsAccount { * the term restarted belongs to a term that has already been settled. */ private Money totalInterestPosted() { + final boolean renewedTerm = isRenewedTerm(); final LocalDate currentTermStartDate = depositStartDate(); Money interestPostedToDate = Money.zero(this.currency); for (final SavingsAccountTransaction transaction : this.transactions) { - if (transaction.isInterestPostingAndNotReversed() - && !DateUtils.isBefore(transaction.getTransactionDate(), currentTermStartDate)) { - interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency)); + if (!transaction.isInterestPostingAndNotReversed()) { + continue; } + // Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the + // previous term's closing interest posting is dated the SAME day the new term starts, so an + // inclusive bound still counts it here while the posting-period walk excludes it -- leaving + // the old term's entire interest subtracted from a maturity amount it was never part of. + if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) { + continue; + } + interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency)); } return interestPostedToDate; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java index 822d6bcb8..b8df20388 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java @@ -70,9 +70,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType); DepositAccountData accountData = null; - Collection onAccountClosureOptions = SavingsEnumerations - .depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT, - DepositAccountOnClosureType.TRANSFER_TO_SAVINGS }); + // Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed + // deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the + // counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright + // by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it + // either. TRANSFER_TO_GL is offered only for fixed deposits because only + // DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg. + final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit() + ? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS, + DepositAccountOnClosureType.TRANSFER_TO_GL } + : new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS }; + Collection onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes); final Collection paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); final Collection savingsAccountDatas = this.savingsAccountReadPlatformService .retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 421ed87ad..914fd86b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -543,9 +543,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId, depositAccountType.getValue()); + // TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one + // handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in + // DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the + // normal Close and Payment screens -- would only get it refused by validateClosing. Collection onAccountClosureOptions = SavingsEnumerations .depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values()) - .filter(type -> includePartialReinvest || !type.isPartialReinvest()) + .filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl()) .toArray(DepositAccountOnClosureType[]::new)); final Collection paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); final Collection savingsAccountDatas = this.savingsAccountReadPlatformService diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 020cdbd2f..108e7421a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} From aa264097e707e7d711cef41b61a37337d5be5fce Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 4 Oct 2026 11:54:28 +0530 Subject: [PATCH 32/39] fix(savings,accounting): fix premature interest calculation date bounds and load interest payable GL mapping on edit Details of changes: 1. Premature Closure 1-Day Shortfall Fix (FixedDepositAccount & RecurringDepositAccount): - In calculatePreMatureInterest, explicitly reset transaction end-of-day balances to preMatureDate before invoking calculateInterestPayable. - Ensure end-of-day balances are restored to current business local date in a finally block. - Resolves the issue where premature closure missed 1 day of interest (e.g., -17.80 clawback) due to transaction end-of-day balances defaulting to the previous day when premature date matched current business date. 2. Interest Transfer Exclusions (SavingsAccountInterestPostingServiceImpl): - Exclude interest-transfer withdrawal transactions from non-interest posting transactions and end-of-day cumulative balance updates for fixed deposits transferring interest to linked accounts. 3. Interest Payable GL Load on Edit (AccountingConstants & ProductToGLAccountMappingReadPlatformServiceImpl): - Added INTEREST_PAYABLE(17) enum constant to CashAccountsForSavings in AccountingConstants. - Mapped CashAccountsForSavings.INTEREST_PAYABLE to SavingProductAccountingDataParams.INTEREST_PAYABLE in ProductToGLAccountMappingReadPlatformServiceImpl. - Fixes the bug where Liability -> Interest Payable Account GL was not loading in the product edit form (e.g., /products/fixed-deposit-products/:id/edit) even after being saved. --- ...AccountMappingReadPlatformServiceImpl.java | 2 ++ .../common/AccountingConstants.java | 1 + .../savings/domain/FixedDepositAccount.java | 21 +++++++----- .../domain/RecurringDepositAccount.java | 21 +++++++----- ...ingsAccountInterestPostingServiceImpl.java | 32 +++++++++++++++++-- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java index 65b754870..2e87bde94 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java @@ -477,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData); } else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) { accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData); + } else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) { + accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData); // Income } else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) { accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData); diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index a5a4ead03..0ce7582f9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -270,6 +270,7 @@ public final class AccountingConstants { INCOME_FROM_INTEREST(12), // LOSSES_WRITTEN_OFF(13), // ESCHEAT_LIABILITY(14), // + INTEREST_PAYABLE(17), // /** * Where the paise dropped from a cash closure are absorbed. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index b43d758ce..5226ede94 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -829,17 +829,22 @@ public class FixedDepositAccount extends SavingsAccount { final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { final MathContext mc = MathContext.DECIMAL64; - final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate); + try { + final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - Money interestOnMaturity = Money.zero(this.currency); + Money interestOnMaturity = Money.zero(this.currency); - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); - interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); + interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + } + + return interestOnMaturity; + } finally { + this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); } - - return interestOnMaturity; } public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java index c4c07b19e..62cb85a65 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java @@ -759,17 +759,22 @@ public class RecurringDepositAccount extends SavingsAccount { final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { final MathContext mc = MathContext.DECIMAL64; - final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate); + try { + final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - Money interestOnMaturity = Money.zero(this.currency); + Money interestOnMaturity = Money.zero(this.currency); - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); - interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); + interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + } + this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods); + return interestOnMaturity; + } finally { + this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); } - this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods); - return interestOnMaturity; } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java index 4b255ddf0..c936dba03 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java @@ -437,14 +437,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI return false; } + protected Collection interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) { + if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) { + return Collections.emptyList(); + } + return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId()); + } + + private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) { + return savingsAccountData.isTransferInterestToLinkedAccount() + && DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId()); + } + + protected List excludeInterestTransferWithdrawals( + final List transactions, final SavingsAccountData savingsAccountData) { + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData); + if (interestTransferWithdrawalIds.isEmpty()) { + return transactions; + } + return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) + .collect(Collectors.toList()); + } + private List retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) { final List listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData); final List orderedNonInterestPostingTransactions = new ArrayList<>(); for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) { if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction()) { + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { orderedNonInterestPostingTransactions.add(transaction); } } @@ -664,10 +688,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) { + final List filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted, + savingsAccountData); // loop over transactions in reverse LocalDate endOfBalanceDate = interestPostingUpToDate; - for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) { - final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i); + for (int i = filteredTransactions.size() - 1; i >= 0; i--) { + final SavingsAccountTransactionData transaction = filteredTransactions.get(i); if (transaction.isNotReversed() && !transaction.isReversalTransaction() && !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) { transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()), From fd827dec274903e6757c91eee20dae95420fa340 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 4 Oct 2026 16:02:25 +0530 Subject: [PATCH 33/39] fix(accounting,branchday): 3-stage FD interest posting in scheduler, fix branch day close business date validation --- .../savings/DepositsApiConstants.java | 6 +- .../service/AccountingProcessorHelper.java | 102 +++++++----- ...DepositProductReadPlatformServiceImpl.java | 35 +++- .../savings/data/DepositProductData.java | 19 +++ .../savings/data/FixedDepositProductData.java | 7 +- .../domain/DepositProductAssembler.java | 9 + .../savings/domain/SavingsProduct.java | 4 + .../SavingsSchedularInterestPoster.java | 156 +++++++++++++++--- 8 files changed, 275 insertions(+), 63 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index fd2b66e0d..aa88c1989 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -252,7 +252,7 @@ public final class DepositsApiConstants { SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(), SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName, - SavingsApiConstants.taxGroupIdParamName)); + SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName)); private static final Set PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName)); @@ -390,6 +390,8 @@ public final class DepositsApiConstants { fixedDepositRequestData.add("paymentChannelToFundSourceMappings"); fixedDepositRequestData.add("feeToIncomeAccountMappings"); fixedDepositRequestData.add("penaltyToIncomeAccountMappings"); + fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName); + fixedDepositRequestData.add("otherGlMappingsMap"); return fixedDepositRequestData; } @@ -501,6 +503,8 @@ public final class DepositsApiConstants { recurringDepositRequestData.add("paymentChannelToFundSourceMappings"); recurringDepositRequestData.add("feeToIncomeAccountMappings"); recurringDepositRequestData.add("penaltyToIncomeAccountMappings"); + recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName); + recurringDepositRequestData.add("otherGlMappingsMap"); return recurringDepositRequestData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 1ee9fe013..062bb76a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -27,6 +27,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.closure.domain.GLClosureRepository; @@ -83,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +@Slf4j @RequiredArgsConstructor public class AccountingProcessorHelper { @@ -662,16 +664,25 @@ public class AccountingProcessorHelper { final GLAccount payableAccount = Optional .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) - .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING))) + .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, + SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account"))) .orElse(controlAccount); - final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) + final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, + SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account")) .orElse(payableAccount); + log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}", + savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(), + controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(), + amount, isReversal); + // A product that names no payable falls back to Savings Control above. The chain below would // then open with Dr Savings Control / Cr Interest On Savings and close with its exact // reverse, the two cancelling: the expense never charged and the member never credited. Such // a product has no liability to draw down, so it keeps the single pair it always posted. if (payableAccount.getId().equals(controlAccount.getId())) { + log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry", + payableAccount.getId()); postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); return; @@ -697,28 +708,34 @@ public class AccountingProcessorHelper { final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType()); final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode); final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO); + log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}", + alreadyAccrued, shortfall, payableIsLiability, isReversal); if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) { - // Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run. + log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]", + interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall); + // Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run. postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, shortfall, false); } - // Out of the payable, into the interest-paid account the product names. - postSavingsLeg(office, currencyCode, payableAccount, interestOnSavingsAccount, savingsId, transactionId, transactionDate, amount, - isReversal); - - // A separate paid account, where the product names one, sits between interest-paid and the - // member; otherwise the payout leaves straight from interest-paid. final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId()); if (hasSeparatePaidAccount) { - postSavingsLeg(office, currencyCode, interestOnSavingsAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]", + payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount); + // Stage 2: Out of the payable, into the interest-paid transit/clearing account + postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // Stage 3: Out of the interest-paid account, into the member's control account + postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + } else { + log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]", + payableAccount.getName(), controlAccount.getName(), amount); + // Payout directly from payable into member's control account + postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); } - - // Into the member's own balance. - final GLAccount payoutFromAccount = hasSeparatePaidAccount ? paidAccount : interestOnSavingsAccount; - postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, - isReversal); } /** @@ -792,34 +809,37 @@ public class AccountingProcessorHelper { return balance == null ? BigDecimal.ZERO : balance; } - private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { - if (savingsProductId == null) { + private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) { + if (savingsProductId == null || accountNames == null || accountNames.length == 0) { return null; } - // The column holds an empty string on a product saved with no named mappings, and casting - // that to jsonb is an error rather than an empty document - so the cast is done inside a - // subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE - // clause in any order, so guarding at the same query level as the cast would only work by - // luck. - final List found = jdbcTemplate.queryForList(""" - SELECT gl.id - FROM (SELECT other_gl_mappings::jsonb AS doc - FROM m_savings_product - WHERE id = ? - AND other_gl_mappings IS NOT NULL - AND other_gl_mappings <> '' - AND other_gl_mappings LIKE '{%') sp - CROSS JOIN LATERAL jsonb_array_elements( - COALESCE(sp.doc -> 'assets', '[]'::jsonb) - || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) - || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) - || COALESCE(sp.doc -> 'income', '[]'::jsonb) - ) AS m(entry) - JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' - WHERE m.entry ->> 'accountName' = ? - LIMIT 1 - """, Long.class, savingsProductId, accountName); - return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); + for (final String accountName : accountNames) { + if (accountName == null) { + continue; + } + final List found = jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?)) + LIMIT 1 + """, Long.class, savingsProductId, accountName); + if (!found.isEmpty()) { + return glAccountRepository.findById(found.get(0)).orElse(null); + } + } + return null; } /** 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 24f1b6aaf..5bb9f4f2c 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 @@ -19,10 +19,13 @@ package org.apache.fineract.portfolio.savings.service; import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collection; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.common.AccountingEnumerations; @@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead } } + private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken>>() {}.getType(); + private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken>>>() {}.getType(); + + private static List> parseOtherGlMappings(final String otherGlMappingsJson) { + if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) { + return null; + } + com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class); + if (!element.isJsonArray()) { + return null; + } + return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE); + } + + private static Map>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) { + if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) { + return null; + } + com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class); + if (!element.isJsonObject()) { + return null; + } + return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE); + } + private abstract static class DepositProductMapper implements RowMapper { private final String schemaSql; @@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,"); sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); sqlBuilder.append("sp.accounting_type as accountingType, "); + sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, "); sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, "); sqlBuilder.append("sp.withhold_tax as withHoldTax,"); sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName "); @@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName); } - return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate, + final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate, compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType, lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax, taxGroupData); + final String otherGlMappingsJson = rs.getString("otherGlMappings"); + data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson)); + data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson)); + return data; } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java index 04c72bf02..85e518bc9 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java @@ -58,6 +58,9 @@ public class DepositProductData { // accounting protected final EnumOptionData accountingRule; + + private List> otherGlMappings; + private Map>> otherGlMappingsMap; protected final Map accountingMappings; protected final Collection paymentChannelToFundSourceMappings; protected final Collection feeToIncomeAccountMappings; @@ -453,4 +456,20 @@ public class DepositProductData { public EnumOptionData getInterestCompoundingPeriodType() { return interestCompoundingPeriodType; } + + public List> getOtherGlMappings() { + return this.otherGlMappings; + } + + public void setOtherGlMappings(List> otherGlMappings) { + this.otherGlMappings = otherGlMappings; + } + + public Map>> getOtherGlMappingsMap() { + return this.otherGlMappingsMap; + } + + public void setOtherGlMappingsMap(Map>> otherGlMappingsMap) { + this.otherGlMappingsMap = otherGlMappingsMap; + } } 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 88004012f..1909f9190 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 @@ -244,7 +244,7 @@ public final class FixedDepositProductData extends DepositProductData { final Collection periodFrequencyTypeOptions = null; final Collection taxGroupOptions = null; - return new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName, + final FixedDepositProductData result = new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName, depositProductData.description, depositProductData.currency, depositProductData.nominalAnnualInterestRate, depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType, depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType, @@ -258,6 +258,9 @@ public final class FixedDepositProductData extends DepositProductData { minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType, minDepositAmount, depositAmount, maxDepositAmount, periodFrequencyTypeOptions, depositProductData.withHoldTax, depositProductData.taxGroup, taxGroupOptions); + result.setOtherGlMappings(depositProductData.getOtherGlMappings()); + result.setOtherGlMappingsMap(depositProductData.getOtherGlMappingsMap()); + return result; } public static FixedDepositProductData lookup(final Long id, final String name) { @@ -609,6 +612,8 @@ public final class FixedDepositProductData extends DepositProductData { this.balanceFrequency = src.balanceFrequency; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; this.renewalGracePeriodDays = src.renewalGracePeriodDays; + this.setOtherGlMappings(src.getOtherGlMappings()); + this.setOtherGlMappingsMap(src.getOtherGlMappingsMap()); } private Boolean withdrawalFeeForTransfers; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java index 64402d2e7..d65793dc8 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java @@ -52,6 +52,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPe import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minBalanceForInterestCalculationParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.otherGlMappingsParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; @@ -247,6 +248,10 @@ public class DepositProductAssembler { fixedDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers")); } + if (command.hasParameter(otherGlMappingsParamName)) { + fixedDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName)); + } + fixedDepositProduct.validateDomainRules(); return fixedDepositProduct; @@ -402,6 +407,10 @@ public class DepositProductAssembler { recurringDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers")); } + if (command.hasParameter(otherGlMappingsParamName)) { + recurringDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName)); + } + recurringDepositProduct.validateDomainRules(); return recurringDepositProduct; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java index 1f21f0a30..d47b85254 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java @@ -1123,6 +1123,10 @@ public class SavingsProduct extends AbstractPersistableCustom { return this.otherGlMappings; } + public void setOtherGlMappings(final String otherGlMappings) { + this.otherGlMappings = otherGlMappings; + } + // Enhanced Savings Product Getters and Setters public String getAccountType() { return accountType; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java index 6928f730b..0db77af5e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -96,6 +97,63 @@ public class SavingsSchedularInterestPoster { } } + private final Map namedGlAccountCache = new HashMap<>(); + + private Long findNamedGlAccount(final Long savingsProductId, final String... accountNames) { + if (savingsProductId == null || accountNames == null || accountNames.length == 0) { + return null; + } + for (final String accountName : accountNames) { + if (accountName == null) { + continue; + } + final String cacheKey = savingsProductId + ":" + accountName.trim().toLowerCase(); + if (this.namedGlAccountCache.containsKey(cacheKey)) { + final Long cached = this.namedGlAccountCache.get(cacheKey); + if (cached != null) { + return cached; + } + continue; + } + final List found = this.jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?)) + LIMIT 1 + """, Long.class, savingsProductId, accountName); + if (!found.isEmpty()) { + this.namedGlAccountCache.put(cacheKey, found.get(0)); + return found.get(0); + } else { + this.namedGlAccountCache.put(cacheKey, null); + } + } + return null; + } + + private void addJournalEntryLeg(final List params, final Long accountId, final Long officeId, + final String currencyCode, final String transactionId, final Long savingsTransactionId, + final LocalDate transactionDate, final JournalEntryType entryType, final BigDecimal amount, + final Long savingsAccountId, final OffsetDateTime auditDatetime, final Long userId, + final LocalDate businessDate) { + params.add(new Object[] { accountId, officeId, null, currencyCode, transactionId, + savingsTransactionId, null, false, null, false, transactionDate, entryType.getValue().longValue(), + amount, null, entryType.getValue().longValue(), savingsAccountId, auditDatetime, auditDatetime, false, + BigDecimal.ZERO, BigDecimal.ZERO, null, transactionDate, null, userId, userId, businessDate }); + } + private void batchUpdateJournalEntries(final List savingsAccountDataList, final HashMap savingsAccountTransactionDataHashMap) throws DataAccessException, NullPointerException { @@ -114,26 +172,86 @@ public class SavingsSchedularInterestPoster { savingsAccountTransactionData.setId(dataFromFetch.getId()); if (savingsAccountData.getGlAccountIdForSavingsControl() != 0 && savingsAccountData.getGlAccountIdForInterestOnSavings() != 0) { - OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime(); - paramsForGLInsertion.add( - new Object[] { savingsAccountTransactionData.getAccountCredit(), savingsAccountData.getOfficeId(), null, - currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), - savingsAccountTransactionData.getId(), null, false, null, false, - savingsAccountTransactionData.getTransactionDate(), JournalEntryType.CREDIT.getValue().longValue(), - savingsAccountTransactionData.getAmount(), null, JournalEntryType.CREDIT.getValue().longValue(), - savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, - null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId, - DateUtils.getBusinessLocalDate() }); + final OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime(); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final String txnIdentifier = SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(); + final Long savingsTxnId = savingsAccountTransactionData.getId(); + final LocalDate txnDate = savingsAccountTransactionData.getTransactionDate(); + final BigDecimal amount = savingsAccountTransactionData.getAmount(); + final Long savingsAccountId = savingsAccountData.getId(); + final Long officeId = savingsAccountData.getOfficeId(); - paramsForGLInsertion - .add(new Object[] { savingsAccountTransactionData.getAccountDebit(), savingsAccountData.getOfficeId(), null, - currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), - savingsAccountTransactionData.getId(), null, false, null, false, - savingsAccountTransactionData.getTransactionDate(), JournalEntryType.DEBIT.getValue().longValue(), - savingsAccountTransactionData.getAmount(), null, JournalEntryType.DEBIT.getValue().longValue(), - savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, - null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId, - DateUtils.getBusinessLocalDate() }); + if (Boolean.TRUE.equals(isOverdraft)) { + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountCredit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountDebit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + } else { + final Long expenseAccountId = savingsAccountData.getGlAccountIdForInterestOnSavings(); + final Long controlAccountId = savingsAccountData.getGlAccountIdForSavingsControl(); + Long payableAccountId = savingsAccountData.getGlAccountIdForInterestPayable(); + final Long productId = savingsAccountData.getSavingsProductId() != null + ? savingsAccountData.getSavingsProductId() + : savingsAccountData.getProductId(); + + if (payableAccountId == null || payableAccountId == 0) { + payableAccountId = findNamedGlAccount(productId, + "Fixed Deposit Interest Payable", "Savings Interest Payable", "Recurring Deposit Interest Payable", + "Interest Payable", "Interest Payable Account"); + } + + final Long paidAccountId = findNamedGlAccount(productId, + "Fixed Deposit Interest Paid", "Savings Interest Paid", "Recurring Deposit Interest Paid", + "Interest Paid", "Interest Paid Account"); + + final boolean hasPayable = payableAccountId != null && payableAccountId != 0 && !payableAccountId.equals(controlAccountId); + final boolean hasPaid = paidAccountId != null && paidAccountId != 0 && !paidAccountId.equals(payableAccountId); + + log.info("SavingsSchedularInterestPoster: accountId={}, productId={}, expense={}, payable={}, paid={}, control={}, amount={}", + savingsAccountId, productId, expenseAccountId, payableAccountId, paidAccountId, controlAccountId, amount); + + if (hasPayable && hasPaid) { + // Stage 1: Dr Interest Expense / Cr Interest Payable + addJournalEntryLeg(paramsForGLInsertion, expenseAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + + // Stage 2: Dr Interest Payable / Cr Interest Paid + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, paidAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + + // Stage 3: Dr Interest Paid / Cr Savings Control + addJournalEntryLeg(paramsForGLInsertion, paidAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, controlAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + } else if (hasPayable) { + // Stage 1: Dr Interest Expense / Cr Interest Payable + addJournalEntryLeg(paramsForGLInsertion, expenseAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + + // Stage 2: Dr Interest Payable / Cr Savings Control + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, controlAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + } else { + // Standard 2-leg entry: Dr Interest Expense / Cr Savings Control + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountCredit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountDebit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + } + } } } From 7068d654226d097e996f5afdc50f0ad2a387fbee Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 5 Oct 2026 12:42:44 +0530 Subject: [PATCH 34/39] feat: teller uniqueness, cashier allocation office check and interest posting report updates --- .../teller/domain/TellerRepository.java | 2 + .../domain/TellerRepositoryWrapper.java | 5 + .../TellerAlreadyExistsException.java | 5 + ...llerManagementReadPlatformServiceImpl.java | 7 +- .../TellerWritePlatformServiceJpaImpl.java | 10 +- .../api/BranchDayRegisterApiResource.java | 49 +++--- .../api/CashierDepositRequestApiResource.java | 20 ++- .../api/CashReconciliationApiResource.java | 4 +- .../cashreport/api/CashReportApiResource.java | 18 +- .../service/CountedCashLedger.java | 12 +- .../service/DenominationBreakdownWriter.java | 8 +- .../SavingsInterestPostingReportService.java | 161 ++++++++++++++---- ...vingsInterestPostingReportApiResource.java | 31 +++- .../tenant/final-changelog-tenant.xml | 1 + ...cation_time_and_deposit_counter_office.xml | 65 +++++++ 15 files changed, 319 insertions(+), 79 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java index 6b4c43e63..ed6891586 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java @@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository, JpaSpecif Collection findTellerByOfficeId(Long officeId); long countByDeletedFalse(); + + long countByOfficeIdAndDeletedFalse(Long officeId); } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java index 494c4069c..b29f3d19c 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java @@ -58,4 +58,9 @@ public class TellerRepositoryWrapper { public boolean existsAny() { return this.repository.countByDeletedFalse() > 0; } + + /** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */ + public boolean existsInOffice(final Long officeId) { + return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0; + } } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java index a465aa44d..973eca197 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java @@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce public TellerAlreadyExistsException() { super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE); } + + public TellerAlreadyExistsException(final String officeName) { + super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.", + officeName); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java index 2f05b3d4a..0af6f6811 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java @@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement @Override public Collection getTellers(Long officeId) { - return retrieveAllTellers(false); + if (officeId == null) { + return retrieveAllTellers(false); + } + // The tellers of that one branch. The officeId used to be ignored, so the head office, whose + // hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close. + return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList(); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java index f4aaf9f4a..e8fe22949 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java @@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer try { this.context.authenticatedUser(); - if (this.tellerRepositoryWrapper.existsAny()) { - throw new TellerAlreadyExistsException(); - } - final Long officeId = command.longValueOfParameterNamed("officeId"); this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json()); @@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer // final Office parent = // validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId); final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + + // One teller (vault) per branch, not one for the whole bank: each branch holds and settles + // its own cash, so a second branch needs a vault of its own. + if (this.tellerRepositoryWrapper.existsInOffice(officeId)) { + throw new TellerAlreadyExistsException(tellerOffice.getName()); + } final Teller teller = Teller.fromJson(tellerOffice, command); // pre save to generate id for use in office hierarchy diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 71920ecff..de005116a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -71,6 +71,20 @@ import org.springframework.transaction.annotation.Transactional; @Produces({ MediaType.APPLICATION_JSON }) public class BranchDayRegisterApiResource { + /** + * The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier + * allocation if that allocation already existed when the cash was counted, otherwise the officer's + * own office. A person moved to another branch's teller keeps what they took at the old branch + * there. + */ + private static final String COUNTER_OFFICE_OF_STD = """ + COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id + JOIN m_tellers t ON t.id = mc.teller_id + WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """ + LIMIT 1), + (SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id)) + """; + private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; @@ -195,8 +209,8 @@ public class BranchDayRegisterApiResource { // Scoped to this office through the officer who took the cash (appuser -> staff -> cashier -> // teller -> office). Unscoped, a branch's own "does the physical count agree with the books" // cross-check was being compared against every branch's counted cash at once, which makes it - // wrong at every office but the first. A row whose officer maps to no cashier is still - // counted, for the same reason as the withdrawal legs in bookBreakdown. + // wrong at every office but the first. A row whose officer has no cashier allocation counts + // at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office. // // Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the // note there on why a manual GL entry's decoded reference is kept and a stamped millisecond @@ -205,23 +219,15 @@ public class BranchDayRegisterApiResource { SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' AND """ + CountedCashLedger.COUNTED_ROW + """ - AND (std.officer_id IS NULL - OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - WHERE au.id = std.officer_id) - OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - JOIN m_tellers t ON t.id = c.teller_id - WHERE au.id = std.officer_id AND t.office_id = ?)) + AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """ + ) = ?) """, BigDecimal.class, Date.valueOf(businessDate), officeId)); final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' AND """ + CountedCashLedger.COUNTED_ROW + """ - AND (std.officer_id IS NULL - OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - WHERE au.id = std.officer_id) - OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - JOIN m_tellers t ON t.id = c.teller_id - WHERE au.id = std.officer_id AND t.office_id = ?)) + AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """ + ) = ?) """, BigDecimal.class, Date.valueOf(businessDate), officeId)); result.put("denominationIn", countedIn); result.put("denominationOut", countedOut); @@ -609,11 +615,9 @@ public class BranchDayRegisterApiResource { // never opened is not waited for. final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId); if (isHeadOffice) { - final List stillOpen = jdbcTemplate.queryForList(""" - SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id - WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ? - ORDER BY o.name - """, String.class, Date.valueOf(businessDate), officeId); + // Any day still open counts, not only today's: a branch whose earlier day was never closed is + // still working and has not posted its interest. + final List stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId); if (!stillOpen.isEmpty()) { throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open", "Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen) @@ -1058,6 +1062,9 @@ public class BranchDayRegisterApiResource { final Map r = rows.get(0); final Map m = new LinkedHashMap<>(); m.put("id", r.get("id")); + // The close screen fetches this office's tellers with it; left out, it fetched every teller + // the user can see, and the head office counted each branch's vault as its own. + m.put("officeId", r.get("office_id")); m.put("businessDate", str(r.get("business_date"))); m.put("openedByName", r.get("opened_by_name")); m.put("openedAt", str(r.get("opened_at"))); @@ -1111,11 +1118,13 @@ public class BranchDayRegisterApiResource { (rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null); final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate); + // By the counter that took the cash. A savings deposit carries no office_id, and the old + // "OR office_id IS NULL" counted every such deposit in the bank at every branch. final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request WHERE status = 'APPROVED' AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? - AND (office_id = ? OR office_id IS NULL) + AND COALESCE(counter_office_id, office_id) = ? """, BigDecimal.class, date, officeId)); // Cash paid over the counter to buy a demand draft is real cash arriving at the branch too -- diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 5b71779ea..003da0a43 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource { client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource { "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), denominationData, activityLog, Timestamp.from(Instant.now()), - ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId + ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser) ); // Globally unique voucher number from the central m_voucher register @@ -691,6 +691,20 @@ public class CashierDepositRequestApiResource { .orElse(Map.of()); } + /** + * The branch whose counter took the cash: the office of the teller the user is allocated to as a + * cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries + * none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it. + */ + private Long counterOfficeId(final AppUser user) { + final List tellerOffice = user.getStaff() == null ? List.of() + : jdbcTemplate.queryForList(""" + SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id + WHERE c.staff_id = ? LIMIT 1 + """, Long.class, user.getStaff().getId()); + return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, account_no, account_type, savings_account_id, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index 19a757709..954adbd8e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -93,7 +93,7 @@ public class CashReconciliationApiResource { + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Out: settlements to vault @@ -123,7 +123,7 @@ public class CashReconciliationApiResource { + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // In: cash exchange — customer gave denomination TO cashier (RECEIVED direction) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 761ca117f..89a350f01 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -671,25 +671,25 @@ public class CashReportApiResource { // day itself: taken from the vault, in over the counter, out over the counter, settled // back. What is left is what should physically be in the drawer. result.put("todayVaultIn", dayLegTotal( - "SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + "SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id" + " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ct.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todaySettledOut", dayLegTotal( - "SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + "SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id" + " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)" + " AND COALESCE(cs.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todayInterCashierIn", dayLegTotal( - "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + "SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ic.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todayInterCashierOut", dayLegTotal( - "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + "SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ic.is_reversed, false) = false", @@ -721,7 +721,7 @@ public class CashReportApiResource { final Long scopedCashierId) { final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : ""; final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : ""; final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : ""; @@ -1171,7 +1171,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Settlements back to vault @@ -1197,7 +1197,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Exchange IN: customer gave denomination to cashier (RECEIVED direction) @@ -1315,13 +1315,13 @@ public class CashReportApiResource { if (cashierId != null) { officerFilter = "AND std.officer_id IN (" + "SELECT au.id FROM m_appuser au " - + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"; + + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"; baseParams = new Object[] { txnDate, cashierId }; } else if (tellerId != null) { officerFilter = "AND std.officer_id IN (" + "SELECT au.id FROM m_appuser au " + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id " - + "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)"; + + "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"; baseParams = new Object[] { txnDate, tellerId }; } else { officerFilter = "AND std.officer_id IN (" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java index d07b43810..9fe4d41da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java @@ -130,9 +130,19 @@ public final class CountedCashLedger { + " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))" + " AND" + REAL_TXN_ID + ") "; + /** + * Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what + * the login counted once this allocation existed. A breakdown is tied to a cashier through its + * officer's login, so without this a person moved from one branch's teller to another's carried + * every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation + * with no recorded creation time keeps its whole history. + */ + public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL" + + " OR std.created_at >= mc.created_at)"; + /** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */ private static String breakdownDiffersFrom(final String amountColumn) { - return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)" + return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)" + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id" + " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index 02a6498af..8fa7c4fd7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -288,7 +288,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'DEPOSIT'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)" @@ -312,7 +312,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'WITHDRAWAL'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)" @@ -409,7 +409,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'DEPOSIT'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)" @@ -433,7 +433,7 @@ public class DenominationBreakdownWriter { + " WHERE std.txn_type = 'WITHDRAWAL'" + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java index e3a7999c5..cc6c6206c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java @@ -222,9 +222,10 @@ public class SavingsInterestPostingReportService { return jdbcTemplate.queryForList(""" SELECT r.id FROM m_savings_interest_posting_report r WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ? - AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?) + AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id + AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?))) ORDER BY r.posting_date, r.office_id, r.savings_product_id - """, Long.class, businessDate, headOfficeId, businessDate); + """, Long.class, businessDate, headOfficeId, businessDate, businessDate); } /** Every approved report still waiting, for the nightly safety net. */ @@ -245,15 +246,15 @@ public class SavingsInterestPostingReportService { // ── reading ────────────────────────────────────────────────────────────────────────────────── /** - * Reports for the screen. A branch user sees only their branch; a head office user sees every - * branch, so it can act for one that did not open. canAct says whether this user may regenerate - * or approve the report. + * Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a + * head office user, is every other branch (or one, with officeId). canAct says whether this user + * may regenerate or approve the report, and blockedReason why not. */ public List> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId, - final AppUser user) { + final String scope, final AppUser user) { final Long userOffice = officeOf(user); - final boolean headOffice = isHeadOffice(userOffice); - final Long branch = headOffice ? officeId : userOffice; + final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice); + final Long branch = others ? officeId : userOffice; final StringBuilder sql = new StringBuilder(""" SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId", @@ -287,6 +288,10 @@ public class SavingsInterestPostingReportService { sql.append(" AND r.office_id = ?"); params.add(branch); } + if (others) { + sql.append(" AND r.office_id <> ?"); + params.add(userOffice); + } sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC"); final LocalDate businessDate = DateUtils.getBusinessLocalDate(); @@ -294,7 +299,9 @@ public class SavingsInterestPostingReportService { for (final Map row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) { final Map out = new LinkedHashMap<>(row); final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue(); - out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate)); + final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate); + out.put("canAct", blocked == null); + out.put("blockedReason", blocked); rows.add(out); } return rows; @@ -339,6 +346,45 @@ public class SavingsInterestPostingReportService { return report; } + /** + * What each savings account has earned so far in its current cycle — what it would be paid if + * interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first + * brought up to the business date (which moves no money) so the figure includes today. + * + *

+ * A branch user sees their own branch; a head office user every branch, or one with officeId. + */ + public List> preview(final Long officeId, final AppUser user) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate)); + + final Long userOffice = officeOf(user); + final Long branch = isHeadOffice(userOffice) ? officeId : userOffice; + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName", + p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance", + d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate", + d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest" + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + JOIN m_savings_product p ON p.id = d.savings_product_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE p.deposit_type_enum = 100 + AND sa.status_enum = ? + AND d.savings_transaction_id IS NULL + """); + final List params = new ArrayList<>(List.of(ACTIVE)); + if (branch != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(branch); + } + sql.append(" ORDER BY o.name, p.name, sa.account_no"); + return jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + /** * Branch and product interest for the head office: per report, how many customers and how much * interest, and where it stands. Superseded reports are left out — each line is the report that @@ -375,9 +421,13 @@ public class SavingsInterestPostingReportService { /** * Branch and product pairs with interest due by the date that have no report yet — the noon job ran - * before they had anything due, or failed. Limited to what the user may act on. + * before they had anything due, or failed. + * + *

+ * {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user, + * lists the other branches — each with canAct, and where the head office may not act for it, why. */ - public List> dueWithoutReport(final LocalDate postingDate, final AppUser user) { + public List> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) { final List> out = new ArrayList<>(); // A future date has nothing due yet, and offering to generate for it would label this cycle's // part-earned interest with a date it has not reached. @@ -386,15 +436,21 @@ public class SavingsInterestPostingReportService { return out; } final Long userOffice = officeOf(user); - for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) { - if (activeReportId(due.productId(), due.officeId(), postingDate) == null - && mayAct(userOffice, due.officeId(), businessDate)) { + final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice); + for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) { + if (others == due.officeId().equals(userOffice)) { + continue; + } + if (activeReportId(due.productId(), due.officeId(), postingDate) == null) { final Map row = new LinkedHashMap<>(); row.put("productId", due.productId()); row.put("productName", productName(due.productId())); row.put("officeId", due.officeId()); - row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId())); + row.put("officeName", officeName(due.officeId())); row.put("postingDate", postingDate.toString()); + final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate); + row.put("canAct", blocked == null); + row.put("blockedReason", blocked); out.add(row); } } @@ -408,36 +464,79 @@ public class SavingsInterestPostingReportService { return !parent.isEmpty() && parent.get(0) == null; } - /** Whether the branch opened its day on the date — the head office may act only for one that did not. */ + /** + * Whether the branch is working on the date: it opened that day, or a day it opened earlier is still + * not closed. A branch with yesterday's day still open is open — it closes that day and goes on. + */ public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) { - final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", - Long.class, officeId, businessDate); + final Long rows = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_branch_day_register + WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?)) + """, Long.class, officeId, businessDate, businessDate); return rows != null && rows > 0; } - private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + /** The branches, other than those given, that still have a day open on or before the date. */ + public List branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) { + final StringBuilder sql = new StringBuilder(""" + SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id + WHERE r.closed_at IS NULL AND r.business_date <= ? + """); + final List params = new ArrayList<>(List.of(businessDate)); + for (final Long exclude : excludeOfficeIds) { + sql.append(" AND r.office_id <> ?"); + params.add(exclude); + } + sql.append(" ORDER BY o.name"); + return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray()); + } + + /** + * Null where the user may generate or approve the branch's report, otherwise why not. + * + *

+ * A branch always acts for itself. The head office acts for another branch only when that branch + * has not opened at all, and only once every branch that did open has closed — the head office's + * own close being the last of the day, so nothing is taken out of a branch that may still open. + */ + private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { if (userOffice == null) { - return false; + return "No branch is set for this user."; } if (userOffice.equals(reportOffice)) { - return true; + return null; } - return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate); + final String branch = officeName(reportOffice); + if (!isHeadOffice(userOffice)) { + return "Only " + branch + " can generate or approve its own interest posting report."; + } + if (branchOpenedOn(reportOffice, businessDate)) { + return branch + " is open — it generates and approves its own report."; + } + final List stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice); + if (!stillOpen.isEmpty()) { + return "The head office can act for " + branch + " only after every open branch has closed. Still open: " + + String.join(", ", stillOpen) + "."; + } + return null; + } + + private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + return actBlockedReason(userOffice, reportOffice, businessDate) == null; } /** @return true where the head office is acting on a branch's behalf */ private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) { final Long userOffice = officeOf(user); - if (userOffice != null && userOffice.equals(reportOffice)) { - return false; + final String blocked = actBlockedReason(userOffice, reportOffice, businessDate); + if (blocked != null) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked); } - if (mayAct(userOffice, reportOffice, businessDate)) { - return true; - } - final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice); - throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", - "Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day " - + branch + " has not opened."); + return !reportOffice.equals(userOffice); + } + + private String officeName(final Long officeId) { + return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId); } private static Long officeOf(final AppUser user) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java index 070e2dbfb..a6a9b1bab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java @@ -65,20 +65,34 @@ public class SavingsInterestPostingReportApiResource { /** A branch user sees their own branch; a head office user every branch, or one with officeId. */ @GET public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status, - @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) { + @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId, + @QueryParam("scope") final String scope) { final AppUser user = context.authenticatedUser(); user.validateHasReadPermission(RESOURCE); - return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList()); + return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList()); + } + + /** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */ + @GET + @Path("/context") + public String userContext() { + final AppUser user = context.authenticatedUser(); + final Long officeId = user.getOffice().getId(); + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + result.put("officeName", user.getOffice().getName()); + result.put("headOffice", reportService.isHeadOffice(officeId)); + return gson.toJson(result); } /** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */ @GET @Path("/due-without-report") - public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) { + public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) { final AppUser user = context.authenticatedUser(); user.validateHasReadPermission(RESOURCE); final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); - return gson.toJson(reportService.dueWithoutReport(date, user)); + return gson.toJson(reportService.dueWithoutReport(date, user, scope)); } /** @@ -99,6 +113,15 @@ public class SavingsInterestPostingReportApiResource { return gson.toJson(result); } + /** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */ + @GET + @Path("/preview") + public String preview(@QueryParam("officeId") final Long officeId) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList()); + } + /** The head office's view: per branch and product, customers paid and interest, over a date range. */ @GET @Path("/summary") diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 3d875a303..242415916 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -163,4 +163,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml new file mode 100644 index 000000000..15523b7b0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + UPDATE m_cashiers mc + SET created_at = (SELECT CAST(MIN(cs.made_on_date_utc) AS TIMESTAMP) + FROM m_portfolio_command_source cs + WHERE cs.entity_name = 'TELLER' AND cs.action_name = 'ALLOCATECASHIER' + AND cs.subresource_id = mc.id) + + + + + + + + + + + + + + + UPDATE m_cashier_deposit_request r + SET counter_office_id = COALESCE( + (SELECT t.office_id FROM m_appuser au + JOIN m_cashiers c ON c.staff_id = au.staff_id + JOIN m_tellers t ON t.id = c.teller_id + WHERE au.id = r.cashier_id + AND (c.created_at IS NULL OR r.submitted_at IS NULL OR r.submitted_at >= c.created_at) + LIMIT 1), + (SELECT au.office_id FROM m_appuser au WHERE au.id = r.cashier_id), + r.office_id) + + + + From 760fff8d3eea0fbb244cc7c7b0b405214563e14e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 5 Oct 2026 16:29:26 +0530 Subject: [PATCH 35/39] fix(permissions): persist manually-enabled Teller/Cashier maker-checker settings Same pattern as 0588_persist_manually_enabled_maker_checker_permissions: converts a manually-applied Configure Maker Checker Tasks change into a repeatable migration, this time for the Teller/Cashier module. Found by diffing can_maker_checker across every permission code common to fineract_lincpay and a reference environment -- ALLOCATECASHIER_TELLER, ALLOCATECASHTOCASHIER_TELLER, SETTLECASHFROMCASHIER_TELLER, and UPDATECASHIERALLOCATION_TELLER were true there but still seeded false here, confirming they were toggled on manually and never captured for any other environment (including UAT). The _CHECKER permissions already exist and are already granted to Clerk/Teller/Branch Manager, so approval works as soon as this migration runs -- no role-grant changes needed. Co-Authored-By: Claude Sonnet 5 --- .../tenant/final-changelog-tenant.xml | 1 + ...92_enable_teller_cashier_maker_checker.xml | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_enable_teller_cashier_maker_checker.xml diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 32c5f1ab9..a5a0a33c8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -165,4 +165,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_enable_teller_cashier_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_enable_teller_cashier_maker_checker.xml new file mode 100644 index 000000000..ae07901d5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_enable_teller_cashier_maker_checker.xml @@ -0,0 +1,33 @@ + + + + + + + + code IN ( + 'ALLOCATECASHIER_TELLER', + 'ALLOCATECASHTOCASHIER_TELLER', + 'SETTLECASHFROMCASHIER_TELLER', + 'UPDATECASHIERALLOCATION_TELLER' + ) + + + + From 88157ee9d6ee3b8dd591766065276b74dfcf5e11 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 6 Oct 2026 10:02:34 +0530 Subject: [PATCH 36/39] Revert "Enforce same-user maker-checker restriction" Reverts commits 2afd4aaf8 and 11a0d502b (both by anshul, same title). Restores the CHECKER_SUPER_USER self-approval bypass in CommandSourceService and the six *EditWritePlatformServiceJpaRepositoryImpl classes, and restores the original isSameMakerCheckerEnabled()/isCheckerSuperUser()-gated same-user check in PortfolioCommandSourceWritePlatformServiceImpl. Removes the 0588_persist_manually_enabled_maker_checker_permissions.xml migration added alongside the restriction. The unconditional same-user block these commits introduced was blocking legitimate single-admin operation/testing (e.g. creating and then activating a client as the same CHECKER_SUPER_USER), with no way to resolve it from inside the app. 11a0d502b's only change (0012_seed_default_platform_api_client.xml comment wording) is a no-op here: that exact text was independently reworded again by 82336cbe9 after 11a0d502b landed, so there is nothing left of 11a0d502b's change to revert in that file. Co-Authored-By: Claude Sonnet 5 --- .../service/CommandSourceService.java | 30 +++++--- ...CommandSourceWritePlatformServiceImpl.java | 28 ++----- ...WritePlatformServiceJpaRepositoryImpl.java | 12 +-- ...WritePlatformServiceJpaRepositoryImpl.java | 12 +-- ...WritePlatformServiceJpaRepositoryImpl.java | 12 +-- ...WritePlatformServiceJpaRepositoryImpl.java | 12 +-- ...WritePlatformServiceJpaRepositoryImpl.java | 12 +-- ...WritePlatformServiceJpaRepositoryImpl.java | 14 +--- ...WritePlatformServiceJpaRepositoryImpl.java | 12 +-- .../tenant/final-changelog-tenant.xml | 1 - ...ally_enabled_maker_checker_permissions.xml | 73 ------------------- .../service/CommandSourceServiceTest.java | 18 ++--- 12 files changed, 48 insertions(+), 188 deletions(-) delete mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index 1f8ef69fa..591e46473 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -137,16 +137,7 @@ public class CommandSourceService { String permission = commandSource.getPermissionCode(); boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission); if (isMakerChecker || result.isRollbackTransaction()) { - // isApprovedByChecker is only ever true here for a genuine checker approval - // (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run - // validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user - // editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker - // genuinely does not apply there). On the ORIGINAL submission this is always false and `user` - // here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any - // other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's - // request without needing the specific _CHECKER permission, it must never let a maker - // auto-approve their own submission just by holding it. - if (isApprovedByChecker) { + if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) { commandSource.markAsChecked(user); } else { if (commandSource.isSanitized()) { @@ -160,6 +151,25 @@ public class CommandSourceService { return result; } + private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) { + if (!user.isCheckerSuperUser()) { + return false; + } + if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName()) + || "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName()) + || "CHARGE".equalsIgnoreCase(commandSource.getEntityName()) + || "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "USER".equalsIgnoreCase(commandSource.getEntityName()) + || "ROLE".equalsIgnoreCase(commandSource.getEntityName())) { + return false; + } + return true; + } + private void sanitizeJson(@NonNull CommandSource commandSource, Set sanitizeKeys) { if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { return; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java index f4c7926ba..5406ac5b4 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java @@ -48,12 +48,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio private final FromJsonHelper fromApiJsonHelper; private final CommandProcessingService processAndLogCommandService; private final SchedulerJobRunnerReadService schedulerJobRunnerReadService; - // No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user - // can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no - // longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a - // live, independently-configurable global switch (System -> Configurations); deleting this field, - // and by extension deciding that switch should stop existing altogether, is a separate decision - // from closing this specific self-approval loophole. private final ConfigurationDomainService configurationService; @Override @@ -129,20 +123,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio AppUser appUser = this.context.authenticatedUser(); String permissionCode = commandSource.getPermissionCode(); appUser.validateHasCheckerPermissionTo(permissionCode); - // Maker-identity rule: the user who submitted a command may never be the one who approves - // (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that - // permission still lets its holder action a DIFFERENT user's request without needing the - // specific _CHECKER permission - see validateHasCheckerPermissionTo() above - it just no - // longer lets them action their own) and not overridable by the enable-same-maker-checker - // global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that - // configuration is left in place and unused for this decision rather than removed, since - // deleting a still-configurable global setting is a separate decision from this one). - AppUser maker = commandSource.getMaker(); - if (maker == null) { - throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); - } - if (Objects.equals(appUser.getId(), maker.getId())) { - throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user."); + if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) { + AppUser maker = commandSource.getMaker(); + if (maker == null) { + throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); + } + if (Objects.equals(appUser.getId(), maker.getId())) { + throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user."); + } } return commandSource; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java index cbb1c1bc8..d0f945f84 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -250,11 +250,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement elsewhere. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); } @@ -310,11 +306,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement elsewhere. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); } 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 9cd21255f..053998bf9 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 @@ -260,11 +260,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -354,11 +350,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsProductSelfApprovalNotAllowedException(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 5fda9a977..3abffe3ad 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 @@ -264,11 +264,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -361,11 +357,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java index 8d617f6f4..ca4b88631 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -203,11 +203,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); } @@ -269,11 +265,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); } 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 0f94f146a..54d4f96b7 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 @@ -420,11 +420,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -528,11 +524,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java index 7eecc2e66..77d7290c9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -172,12 +172,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request, - // it just no longer lets them bypass the hold on their own (matches - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the - // equivalent rule for the classic command pipeline). - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); } @@ -219,12 +214,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request, - // it just no longer lets them bypass the hold on their own (matches - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the - // equivalent rule for the classic command pipeline). - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); } 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 a135ebead..b2ce9c6b8 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 @@ -882,11 +882,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new ShareProductSelfApprovalNotAllowedException(productId); } @@ -972,11 +968,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareProductNoPendingEditException(productId)); - // Maker-identity rule: the same user can never approve their own submission, even when they - // hold CHECKER_SUPER_USER - see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the - // equivalent enforcement in the classic command pipeline. - if (pendingEdit.getMaker().getId().equals(checker.getId())) { + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { throw new ShareProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index a5a0a33c8..0f961c93b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -163,7 +163,6 @@ - diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml deleted file mode 100644 index bbee551d9..000000000 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - code IN ( - 'UPDATE_CURRENCY', - 'CREATE_FUND', - 'UPDATE_WORKINGDAYS', - 'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE', - 'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK', - 'UPDATE_BRANCHPROFILE', - 'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE', - 'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE', - 'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE', - 'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT', - 'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK', - 'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT', - 'CREATE_USERAGREEMENT', - 'UPDATE_BATCH_BUSINESS_STEP' - ) - - - - diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index ee634c61c..c4db9dadf 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -116,11 +116,7 @@ public class CommandSourceServiceTest { } @Test - public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() { - // A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission - - // CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see - // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This - // replaces the old bypass this test used to assert. + public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() { NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); CommandSource commandSource = Mockito.mock(CommandSource.class); @@ -129,8 +125,6 @@ public class CommandSourceServiceTest { Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT"); Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT"); Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW"); - Mockito.when(commandSource.getId()).thenReturn(300L); - Mockito.when(commandSource.getResourceId()).thenReturn(400L); Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true); Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); @@ -138,12 +132,12 @@ public class CommandSourceServiceTest { .withCommandId(1L).build(); Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); - Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { - underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); - }); + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, superUser, false); - Mockito.verify(commandSource).markAsAwaitingApproval(); - Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(superUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); } @Test From 6c7c0f84451a8c5aa2c6e5c6e12f2e410b53491f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 6 Oct 2026 12:42:12 +0530 Subject: [PATCH 37/39] build: freeze the default (non-release/maintenance branch) jar version git-versioning's rev{} fallback previously derived the SNAPSHOT version from the nearest reachable git tag (major.(minor+1).0-SNAPSHOT), so the built jar's name drifted as tags moved. Pins it to a fixed 1.15.0-SNAPSHOT instead so the jar name stays predictable across builds on dev-latest/feature branches. release/*.*.* and maintenance/*.* branches still version off their own tags, unchanged. Co-Authored-By: Claude Sonnet 5 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index fc2ca9941..c2dc2e63f 100644 --- a/build.gradle +++ b/build.gradle @@ -154,7 +154,7 @@ gitVersioning.apply { } } rev { - version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT' + version = '1.15.0-SNAPSHOT' } } From 643427bd1abc5791b174c2e4d1764a132f760134 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 6 Oct 2026 19:03:51 +0530 Subject: [PATCH 38/39] fix(shares): correct share transaction lookup and validate NONE settlements redemptionValueOf() read from "m_share_account_transaction" with a "share_account_id" column. The table is m_share_account_transactions and the column is account_id, so every share withdrawal approval failed with a BadSqlGrammarException on the Share Sundry balance guard, before any posting was attempted. The lookup now also filters on is_active, matching ShareAdvanceCollectionApiResource.shareValueOf(), so a reversed purchase cannot price a redemption. validate() branched on "GL" versus everything else, but approve() handles a third settlement mode: submit() downgrades a request to NONE when the clerk names the product's own Share Sundry GL as the destination, and that mode posts Dr Share Capital / Cr Share Sundry and stops. Validating it as a savings settlement reported a missing fund source mapping that no posting in this mode ever reads, so a correct request showed a failed check. NONE is now reported as not applicable for both the linked-savings and the fund-source checks. Co-Authored-By: Claude Opus 5 (1M context) --- ...lerkShareWithdrawalRequestApiResource.java | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index da1a79dc7..e7dedf034 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -451,8 +451,18 @@ public class ClerkShareWithdrawalRequestApiResource { // not-applicable rather than failed for a GL request — a share account with no linked // savings is a perfectly valid GL-settlement case. final boolean glSettlementMode = "GL".equals(record.get("settlement_mode")); - checks.add(glSettlementMode - ? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer") + // 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who + // names the product's own Share Sundry GL as the destination is saying "stop here", so the + // request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry + // and nothing further. Like a GL settlement it never reaches a savings account, so the + // savings-side checks below do not apply to it either — running them reports a missing fund + // source mapping that no posting in this mode is ever going to read. + final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode")); + final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement; + checks.add(noSavingsLeg + ? checkResult("Linked savings account is Active", true, sundryOnlySettlement + ? "Not applicable — the redemption rests in Share Sundry" + : "Not applicable — settlement is a GL book-transfer") : checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300, savingsAccountId == null ? "No linked savings account configured" : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus))); @@ -541,6 +551,9 @@ public class ClerkShareWithdrawalRequestApiResource { final boolean settlementGlPresent = glAccountIdObj != null; checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent, settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing")); + } else if (sundryOnlySettlement) { + checks.add(checkResult("Settlement destination recorded", true, + "Share Sundry — the redemption stops there, so no fund source mapping is read")); } else { boolean glMappingOk = false; String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check"; @@ -992,8 +1005,8 @@ public class ClerkShareWithdrawalRequestApiResource { // The purchase that issued the holding - type 500. Its unit price is what the member paid. final BigDecimal unitPrice = jdbcTemplate .queryForList(""" - SELECT unit_price FROM m_share_account_transaction - WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL + SELECT unit_price FROM m_share_account_transactions + WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL ORDER BY id DESC LIMIT 1 """, BigDecimal.class, shareAccountId) .stream().findFirst().orElse(null); From 99a7e04872f996f65db943fc46143c986dc3ae22 Mon Sep 17 00:00:00 2001 From: keshav Date: Wed, 7 Oct 2026 10:14:04 +0530 Subject: [PATCH 39/39] feat: updates for tds services, rbi reserve config, and cashier api resources --- ...ountTransfersWritePlatformServiceImpl.java | 25 +- .../account/starter/AccountConfiguration.java | 7 +- .../api/BranchDayRegisterApiResource.java | 25 +- .../api/CashierDepositRequestApiResource.java | 19 +- .../cashreport/api/CashReportApiResource.java | 16 +- .../api/CashSettlementApiResource.java | 6 +- .../api/CashTransferApiResource.java | 6 +- .../api/CashierGlDenominationApiResource.java | 2 +- .../ShareAdvanceCollectionApiResource.java | 10 +- .../api/RbiReserveConfigApiResource.java | 8 +- .../api/RbiReserveReturnApiResource.java | 315 +++++++++-- .../service/RbiFormAAutoSaveService.java | 4 +- .../SavingsDailyInterestService.java | 6 +- .../DepositAccountDomainServiceJpa.java | 30 ++ .../SponsorDDReadPlatformServiceImpl.java | 6 +- .../tds/api/TdsInterestApiResource.java | 4 +- .../tds/api/TdsRecordsApiResource.java | 490 ++++++++++++++++++ .../tds/data/TdsDeclarationData.java | 27 + .../portfolio/tds/data/TdsDeducteeData.java | 27 +- .../service/SavingsInterestTdsService.java | 389 ++++++++++++++ .../TdsInterestReadPlatformService.java | 4 +- .../TdsInterestReadPlatformServiceImpl.java | 187 ++++--- .../portfolio/tds/service/TdsSql.java | 95 ++++ .../src/main/resources/application.properties | 4 +- .../tenant/final-changelog-tenant.xml | 2 + .../0592_tds_declarations_and_challans.xml | 217 ++++++++ ...3_tds_interest_receipts_and_deductions.xml | 143 +++++ .../savings/domain/SavingsAccount.java | 23 + 28 files changed, 1908 insertions(+), 189 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsRecordsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeclarationData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsSql.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_tds_declarations_and_challans.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_tds_interest_receipts_and_deductions.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 172e39de7..9a34bd589 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -79,6 +79,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @@ -102,6 +103,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer private final CurrentAccountTransactionRepository currentAccountTransactionRepository; private final AccountingProcessorHelper accountingProcessorHelper; private final KycRestrictionGuard kycRestrictionGuard; + private final SavingsInterestTdsService savingsInterestTdsService; @Transactional @Override @@ -412,6 +414,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer toSavingsAccount, withdrawal, deposit); this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails); transferTransactionId = accountTransferDetails.getId(); + // FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted. + this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit, + AccountTransferType.fromInt(accountTransferDTO.getTransferType())); } else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) { @@ -703,8 +708,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer currentAccountRepository.save(account); if (account.getProductId() != null && txn.getId() != null) { final String currentTxnId = "CA" + txn.getId(); - accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(), - account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(), + accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(), + account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getId(), currentTxnId, txnDate, amount); } return txn; @@ -715,7 +720,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient. */ private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) { kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; @@ -735,7 +740,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent, + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent, counterpartEntityId); return txn; } @@ -744,7 +749,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Deposit amount into a current account and save the transaction. */ private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) { kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; @@ -757,7 +762,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent, + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent, counterpartEntityId); return txn; } @@ -803,13 +808,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final String currentTxnId = "CA" + txn.getId(); if (isWithdrawal) { // DR: current account GL CR: counterpart (savings/current) GL - accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), - account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId, + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId, currentTxnId, txnDate, amount); } else { // DR: counterpart (savings/current) GL CR: current account GL - accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), - counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(), + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(), currentTxnId, txnDate, amount); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java index 4c3d4bcfa..eee9b3400 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java @@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -97,12 +98,14 @@ public class AccountConfiguration { GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, CurrentAccountTransactionRepository currentAccountTransactionRepository, - AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) { + AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard, + SavingsInterestTdsService savingsInterestTdsService) { return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, - currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard); + currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard, + savingsInterestTdsService); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index de005116a..82224e25c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -122,11 +122,26 @@ public class BranchDayRegisterApiResource { result.put("officeId", officeId); result.put("canOperate", canOperateDay(currentUser)); result.put("days", mapDays(rows)); - result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString()); + result.put("today", DateUtils.getBusinessLocalDate().toString()); result.put("openDay", findOpenDay(officeId)); return gson.toJson(result); } + // ── GET /v1/branchday/today ── the bank's "today" ───────────────────────── + + /** + * The business date while enable-business-date is on, the tenant's own date while it is off. + * Every screen that defaults a date to "today" reads this, rather than the browser clock or the + * business-date API (which returns the stored date even when the feature is off). Open to any + * signed-in user — cashiers and clerks cannot read the configuration that says which applies. + */ + @GET + @Path("today") + public String today() { + this.context.authenticatedUser(); + return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString())); + } + // ── GET /v1/branchday/missed ── days left open ──────────────────────────── /** @@ -139,7 +154,7 @@ public class BranchDayRegisterApiResource { public String missed(@QueryParam("officeId") final Long officeIdParam) { final AppUser currentUser = this.context.authenticatedUser(); final Long officeId = resolveOffice(officeIdParam, currentUser); - final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate today = DateUtils.getBusinessLocalDate(); final List> rows = jdbcTemplate.queryForList(""" SELECT r.id, r.office_id, o.name AS office_name, r.business_date, @@ -253,7 +268,7 @@ public class BranchDayRegisterApiResource { final var root = this.fromJsonHelper.parse(body); final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); final LocalDate businessDate = extractDate(root, "businessDate", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + DateUtils.getBusinessLocalDate()); final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); @@ -447,7 +462,7 @@ public class BranchDayRegisterApiResource { final var root = this.fromJsonHelper.parse(body); final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); final LocalDate businessDate = extractDate(root, "businessDate", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + DateUtils.getBusinessLocalDate()); final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); @@ -801,7 +816,7 @@ public class BranchDayRegisterApiResource { final Long officeId = resolveOffice(officeIdParam, currentUser); final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank()) ? LocalDate.parse(businessDateParam) - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final List registerIds = jdbcTemplate.queryForList( "SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 003da0a43..9ffd57359 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -727,7 +727,7 @@ public class CashierDepositRequestApiResource { final Long savingsAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -761,7 +761,7 @@ public class CashierDepositRequestApiResource { final Long accountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -790,7 +790,7 @@ public class CashierDepositRequestApiResource { final Long accountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -844,7 +844,7 @@ public class CashierDepositRequestApiResource { return; } - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -867,7 +867,7 @@ public class CashierDepositRequestApiResource { final Long currentAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -976,7 +976,7 @@ public class CashierDepositRequestApiResource { final Object paymentTypeId, final String txnDateStr, final String note, final boolean isDeposit) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; // Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra) @@ -1073,8 +1073,9 @@ public class CashierDepositRequestApiResource { value, count, BigDecimal.valueOf((long) value * count), // Dated on the request's own date, which is what the branch day's book figure reads. // CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the - // drawer on another and leave both days' cash wrong. - txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null, + // drawer on another and leave both days' cash wrong. No date falls back to the + // business date for the same reason. + Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()), cashierId, cashierUsername, paymentTypeName ); } @@ -1179,7 +1180,7 @@ public class CashierDepositRequestApiResource { final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE); return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } catch (Exception e) { - return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 89a350f01..20d360fa0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -83,7 +83,7 @@ public class CashReportApiResource { public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side, @QueryParam("status") final String status) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); final List> rows = new ArrayList<>(); @@ -470,7 +470,7 @@ public class CashReportApiResource { @GET @Path("closing") public String closing(@QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); // A closing report for a cashier is that one drawer, so it is scoped the way `excess` and // `cashierPosition` already are; a head cashier or admin gets null and still sees the branch. @@ -938,7 +938,7 @@ public class CashReportApiResource { return gson.toJson(result); } - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String today = DateUtils.getBusinessLocalDate().toString(); // Find the earliest transfer date before today for which no settlement exists on or after that date final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled " @@ -1001,7 +1001,7 @@ public class CashReportApiResource { @Path("cashier-position") public String cashierPosition(@QueryParam("cashierId") Long cashierId, @QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString(); // CASHIER role: always scope to their own position only final Long scopedId = cashierRoleHelper.getCashierIdForFiltering(); @@ -1032,7 +1032,7 @@ public class CashReportApiResource { @GET @Path("cashier-position/me") public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString(); Long appUserId; try { appUserId = context.authenticatedUser().getId(); @@ -1070,7 +1070,7 @@ public class CashReportApiResource { public String vaultPosition(@QueryParam("tellerId") final Long tellerId, @QueryParam("asOfDate") final String asOfDate) { final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + : DateUtils.getBusinessLocalDate().toString(); if (tellerId == null) { final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class); @@ -1092,7 +1092,7 @@ public class CashReportApiResource { @Path("cashier-morning") public String cashierMorning(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); if (cashierId == null) { Map empty = new LinkedHashMap<>(); empty.put("cashierId", null); @@ -1304,7 +1304,7 @@ public class CashReportApiResource { @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); final int resolvedLimit = (limit != null && limit > 0) ? limit : 20; final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 847ea66a8..61d6bbab8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -89,7 +89,7 @@ public class CashSettlementApiResource { } final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull() - ? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + ? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate(); final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString() : null; final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() @@ -226,7 +226,7 @@ public class CashSettlementApiResource { } glDispatchService.allocateCashToCashier(glTellerId, settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), + DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash settlement #" + id + (reason != null ? ": " + reason : "")); } catch (Exception ignored) { // intentionally ignored @@ -247,7 +247,7 @@ public class CashSettlementApiResource { throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required", "cashierId query param is required."); } - final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)", Integer.class, cashierId, settlementDate); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 5f5c64b62..abe44dc8d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -146,7 +146,7 @@ public class CashTransferApiResource { } catch (Exception ignored) { // intentionally ignored } - final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate(); glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total, currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE), note != null ? note : ""); @@ -206,7 +206,7 @@ public class CashTransferApiResource { } glDispatchService.settleCashFromCashier(glTellerId, transfer.getToCashierId(), transfer.getTransferAmount(), "INR", - java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), + DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash transfer #" + id + (reason != null ? ": " + reason : "")); } catch (Exception ignored) { // intentionally ignored @@ -291,7 +291,7 @@ public class CashTransferApiResource { "Vault/teller " + fromTellerId + " was not found."); } - final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate(); final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate); final List registerIds = jdbcTemplate.query(""" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java index 002638999..6aaa12dfb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java @@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource { final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root); final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank()) ? LocalDate.parse(txnDateStr) - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) { return "{\"result\":\"skipped\"}"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index b3164ea4a..9a02c384d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource { final Long resolvedClientId = toLong(dep.get("client_id")); final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String receiptNo = nextReceiptNo(); // What the cashier actually typed at save time, when they typed anything -- that is what // the Apply Share screen's Description column exists to show. Falls back to a plain @@ -927,7 +927,7 @@ public class ShareAdvanceCollectionApiResource { final Object submittedAt = req.get("submitted_at"); final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String voucherNo = (String) req.get("voucher_no"); final String receiptNo = nextReceiptNo(); final String reqDescription = (String) req.get("description"); @@ -1006,7 +1006,7 @@ public class ShareAdvanceCollectionApiResource { final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId); final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR"; final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); // What the cashier actually typed at save time, when they typed anything -- same preference // as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by @@ -1093,7 +1093,7 @@ public class ShareAdvanceCollectionApiResource { final Object submittedAt = req.get("submitted_at"); final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String receiptNo = nextReceiptNo(); final String reqDescription = (String) req.get("description"); @@ -1338,7 +1338,7 @@ public class ShareAdvanceCollectionApiResource { private LocalDate parseDate(final String raw) { if (raw == null || raw.isBlank()) { - return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + return DateUtils.getBusinessLocalDate(); } try { return LocalDate.parse(raw); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java index 3b40943d3..afa3a01a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -210,12 +210,14 @@ public class RbiReserveConfigApiResource { ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter"); } else { ROLE_GROUPS.put(role, "RESERVE"); + // Both ratios are settled on Form 1 - Part B is section 18, Part C section 24 - so + // the part is named rather than a separate return that does not exist. if ("SLR_INVESTMENTS".equals(role)) { - ROLE_USAGE.put(role, "Form 9 (SLR) — maintained"); + ROLE_USAGE.put(role, "Form 1 Part C (SLR) — maintained"); } else if ("DEMAND_TIME_LIABILITIES".equals(role)) { - ROLE_USAGE.put(role, "Form 1 & Form 9 — base"); + ROLE_USAGE.put(role, "Form 1 — base for both ratios"); } else { - ROLE_USAGE.put(role, "Form 1 (CRR) — maintained"); + ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained"); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java index b8e2c98a7..2009a31a8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -1084,6 +1084,15 @@ public class RbiReserveReturnApiResource { */ private static LocalDate requirePastOrPresent(final LocalDate date, final String what) { final LocalDate today = DateUtils.getBusinessLocalDate(); + // A year the ledger could not hold. A date field hands over whatever has been typed so far, + // so a half-typed year arrives as a real date - "0026-09-30" parses cleanly - and the return + // then reads a period no bank has entries for and reports every line nil. + if (date.getYear() < 1900) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.year.incomplete", + what + " (" + date + ") has a year below 1900, which looks like a date still being " + + "typed rather than a period to report.", + what, date.toString()); + } if (date.isAfter(today)) { throw new PlatformDataIntegrityException("error.msg.rbireservereturn.future.date", what + " (" + date + ") is in the future; the bank's business date is " + today @@ -1130,6 +1139,8 @@ public class RbiReserveReturnApiResource { public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam, @QueryParam("firstColumnDate") final String firstColumnDateParam, @QueryParam("secondColumnDate") final String secondColumnDateParam, + @QueryParam("firstBaseDate") final String firstBaseDateParam, + @QueryParam("secondBaseDate") final String secondBaseDateParam, @QueryParam("officeId") final Long officeId) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); @@ -1174,15 +1185,51 @@ public class RbiReserveReturnApiResource { } // The third column is the base the current fortnight's ratios are charged on - the last - // Friday of the second preceding fortnight - not the reporting date. + // matching close one month earlier - not the reporting date. // // It was the reporting date. Since the return is now made up to the second column, that // made the third column a copy of the second: two identically dated columns carrying // identical figures, which reads as a filled form and says nothing. The base belongs on the // face of the form because every required amount below is computed from it, and a reviewer // cannot check a requirement whose base is not stated. - final LocalDate currentBase = formOneBaseDate(currentClose); - final List columnDates = List.of(previousClose, currentClose, currentBase); + // Each close has its own base, and either can be given outright. + // + // Derived, the base is the matching close one month earlier, which is + // what the rules fix it at. But a bank migrating onto the system, or filing a corrected + // return for a period whose base fell before its ledger starts, has a base date that the + // rule produces and the ledger cannot answer - and the ratio then reads nil against real + // liabilities. Given explicitly, the date entered is used and stated on the form, so a + // reader can see which base each column was charged on. + final LocalDate previousBase = firstBaseDateParam == null || firstBaseDateParam.isBlank() + ? formOneBaseDate(previousClose) + : requirePastOrPresent(LocalDate.parse(firstBaseDateParam), "The first column's base date"); + final LocalDate currentBase = secondBaseDateParam == null || secondBaseDateParam.isBlank() + ? formOneBaseDate(currentClose) + : requirePastOrPresent(LocalDate.parse(secondBaseDateParam), "The second column's base date"); + + // A base dated after the close it serves would charge the ratio on liabilities the bank had + // not yet taken on - the figure would be arithmetically fine and mean nothing. + if (previousBase.isAfter(previousClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close", + "The first column's base date (" + previousBase + ") falls after the close it is charged on (" + + previousClose + ").", + previousBase.toString(), previousClose.toString()); + } + if (currentBase.isAfter(currentClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close", + "The second column's base date (" + currentBase + ") falls after the close it is charged on (" + + currentClose + ").", + currentBase.toString(), currentClose.toString()); + } + + // Two columns, one per fortnight close. The base dates are not columns of their own: they + // are stated against the lines they settle, and a third column headed with one printed the + // base's own balances beside the closes', which read as a third period the bank was + // reporting rather than as the figures the ratios were charged on. + final List columnDates = List.of(previousClose, currentClose); + // One base per column, in the same order, so each column is charged on its own base rather + // than both on one. + final List baseDates = List.of(previousBase, currentBase); final Map response = new LinkedHashMap<>(); response.put("formCode", "FORM_1_STATUTORY"); @@ -1190,6 +1237,9 @@ public class RbiReserveReturnApiResource { response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966"); response.put("reportingDate", reportingDate.toString()); response.put("columns", columnDates.stream().map(LocalDate::toString).toList()); + // Stated beside the columns so a reader can see which date each ratio was charged on, + // without the base needing a column of its own. + response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList()); final Rate crr = rateInForce("CRR", reportingDate); final Rate slr = rateInForce("SLR", reportingDate); @@ -1197,10 +1247,14 @@ public class RbiReserveReturnApiResource { response.put("slrPercentage", slr == null ? null : slr.percentage); final List> parts = new ArrayList<>(); - parts.add(formOnePartA(columnDates, officeId, crr, slr)); + parts.add(formOnePartA(columnDates, baseDates, officeId, crr, slr)); response.put("partA", parts.get(0)); - response.put("partB", formOnePartB(columnDates, officeId, crr)); - response.put("partC", formOnePartC(columnDates, officeId, slr)); + response.put("partB", formOnePartB(columnDates, baseDates, officeId, crr)); + response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr)); + + // Every day of the month the closes fall in, charged on each day's own base. The form + // reports two closes; compliance turns on every day between them. + response.put("daily", formOneDaily(previousClose.withDayOfMonth(1), currentClose, officeId, crr, slr)); final List unmet = new ArrayList<>(); if (crr == null) { @@ -1219,9 +1273,8 @@ public class RbiReserveReturnApiResource { final BigDecimal baseLiabilities = formOneNetLiabilities(currentBase, officeId); if (baseLiabilities.compareTo(BigDecimal.ZERO) == 0) { unmet.add("Both ratios are charged on the net liabilities of " + currentBase - + " — the last Friday of the second preceding fortnight — and the ledger holds " - + "none on that date. The required amounts are therefore nil. They are nil " - + "because the base is nil, not because nothing is required."); + + ", and the ledger holds none on that date. The required amounts are therefore " + + "nil — nil because the base is nil, not because nothing is required."); } response.put("unmetRequirements", unmet); @@ -1229,7 +1282,7 @@ public class RbiReserveReturnApiResource { } /** Part A: the liabilities and assets the two ratios are worked out from. */ - private Map formOnePartA(final List dates, final Long officeId, final Rate crr, + private Map formOnePartA(final List dates, final List bases, final Long officeId, final Rate crr, final Rate slr) { final List> lines = new ArrayList<>(); @@ -1279,7 +1332,25 @@ public class RbiReserveReturnApiResource { for (int i = 0; i < dates.size(); i++) { iv.add(totalI.get(i).subtract(totalIII.get(i)).max(BigDecimal.ZERO).add(totalII.get(i))); } - lines.add(formOneTotal("IV. TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24", iv)); + // The form carries IV twice, and the difference between the two is the whole of how the + // ratios work. (a) is this column's own liabilities - what the bank owed on the close being + // reported. (b) is the base: the same figure read at the earlier close, and the only one the + // requirement is actually charged on. A form showing one IV reads as though the bank had to + // hold a ratio of today's liabilities, which is not what either section says. + lines.add(formOneTotal( + "IV. a) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [CURRENT FORTNIGHT]", iv)); + final List ivBase = new ArrayList<>(); + final List ivBaseDates = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + final LocalDate base = baseFor(bases, dates, i); + ivBaseDates.add(base.toString()); + ivBase.add(formOneNetLiabilities(base, officeId)); + } + final Map ivBaseRow = formOneTotal( + "IV. b) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [SECOND PRECEDING FORTNIGHT]", + ivBase); + ivBaseRow.put("baseDates", ivBaseDates); + lines.add(ivBaseRow); final List v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0); @@ -1311,37 +1382,25 @@ public class RbiReserveReturnApiResource { } lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii)); - // The liabilities the ratios are charged on: the last Friday of the second preceding - // fortnight, read per column rather than once, so each column states its own base. - final List baseDateDtl = new ArrayList<>(); - final List baseDates = new ArrayList<>(); - for (final LocalDate date : dates) { - final LocalDate base = formOneBaseDate(date); - baseDates.add(base.toString()); - baseDateDtl.add(formOneNetLiabilities(base, officeId)); - } - final Map baseRow = formOneTotal( - "DEMAND AND TIME LIABILITIES OF THE BASE DATE — last Friday of the second preceding fortnight", - baseDateDtl); - baseRow.put("baseDates", baseDates); - lines.add(baseRow); - + // The base liabilities are IV(b) above - it used to be repeated as a row of its own at the + // foot of the part, which put the same figure on the form twice under two different names. final Map part = new LinkedHashMap<>(); part.put("title", "PART A"); part.put("lines", lines); - part.put("baseDates", baseDates); - part.put("baseDateLiabilities", baseDateDtl); + part.put("baseDates", ivBaseDates); + part.put("baseDateLiabilities", ivBase); return part; } /** Part B: compliance with section 18 — the cash reserve. */ - private Map formOnePartB(final List dates, final Long officeId, final Rate crr) { + private Map formOnePartB(final List dates, final List bases, final Long officeId, final Rate crr) { final List> lines = new ArrayList<>(); final List required = new ArrayList<>(); final List maintained = new ArrayList<>(); - for (final LocalDate date : dates) { - final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId); + for (int i = 0; i < dates.size(); i++) { + final LocalDate date = dates.get(i); + final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId); required.add(crr == null ? BigDecimal.ZERO : base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP)); // X = V + VI + VIII, the form's own definition. @@ -1354,8 +1413,12 @@ public class RbiReserveReturnApiResource { maintained.add(cash.add(currentAccounts).add(netCurrent)); } - lines.add(formOneTotal("IX. " + (crr == null ? "CRR" : crr.percentage + "%") - + " of IV as on the last Friday of the second preceding fortnight", required)); + // The rate is named against IV(b), not IV, because that is the figure it is charged on. The + // template's own wording says "Last Friday of the Second Preceding fortnight"; this bank's + // base rule is the matching close of the previous month, so the line says IV(b) and leaves + // the base date itself to the column heading rather than claiming a Friday it did not use. + lines.add(formOneTotal("IX. CRR REQUIRED TO BE MAINTAINED — " + (crr == null ? "rate not set" : crr.percentage + "% of IV(b)"), + required)); lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained)); final Map part = new LinkedHashMap<>(); @@ -1368,15 +1431,16 @@ public class RbiReserveReturnApiResource { } /** Part C: compliance with section 24 — the liquid assets. */ - private Map formOnePartC(final List dates, final Long officeId, final Rate slr) { + private Map formOnePartC(final List dates, final List bases, final Long officeId, final Rate slr) { final List> lines = new ArrayList<>(); final List required = new ArrayList<>(); final List cashAndOther = new ArrayList<>(); final List gold = new ArrayList<>(); final List securities = new ArrayList<>(); - for (final LocalDate date : dates) { - final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId); + for (int i = 0; i < dates.size(); i++) { + final LocalDate date = dates.get(i); + final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId); final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO : base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); required.add(slrRequired); @@ -1395,8 +1459,9 @@ public class RbiReserveReturnApiResource { securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount); } - lines.add(formOneTotal("XI. " + (slr == null ? "SLR" : slr.percentage + "%") - + " of IV as on the last Friday of the second preceding fortnight", required)); + // Charged on IV(b) like the cash reserve above, and named the same way. + lines.add(formOneTotal("XI. SLR REQUIRED TO BE MAINTAINED — " + (slr == null ? "rate not set" : slr.percentage + "% of IV(b)"), + required)); lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED")); lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1)); lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1)); @@ -1413,6 +1478,115 @@ public class RbiReserveReturnApiResource { return part; } + /** + * Every day of the period the form covers, with what each ratio required that day and what was + * held against it. + * + *

+ * The form itself reports two closes, and a bank that held nothing for thirteen days and topped + * up on the fourteenth has a form that reads as compliant. Both ratios are reckoned over the + * whole period, so the day-by-day position is what the compliance actually turns on - and it is + * the figure an inspector asks for first. + * + *

+ * Each day is charged on ITS OWN base, read through the same rule the columns use, so a day in + * the first fortnight is measured against a different requirement from one in the second. A + * single required amount applied across the month would show every day of one fortnight passing + * or failing for the wrong reason. + * + * @param from the first day reported, usually the start of the month the closes fall in. + * @param to the last, which is the later close. + */ + private Map formOneDaily(final LocalDate from, final LocalDate to, final Long officeId, + final Rate crr, final Rate slr) { + final List> days = new ArrayList<>(); + BigDecimal crrTotal = BigDecimal.ZERO; + BigDecimal slrTotal = BigDecimal.ZERO; + BigDecimal crrLowest = null; + BigDecimal slrLowest = null; + LocalDate crrLowestOn = null; + LocalDate slrLowestOn = null; + int crrShortDays = 0; + int slrShortDays = 0; + + for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) { + final BigDecimal base = formOneNetLiabilities(formOneBaseDate(day), officeId); + final BigDecimal crrRequired = crr == null ? BigDecimal.ZERO + : base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO + : base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + + final BigDecimal crrHeld = formOnePartBMaintained(day, officeId); + // SLR counts the cash held OVER the section 18 requirement, plus the other balances and + // the securities - the same definition Part C uses, so the two cannot disagree. + final BigDecimal excessCash = crrHeld.subtract(crrRequired).max(BigDecimal.ZERO); + final BigDecimal slrHeld = excessCash + .add(figureFor("FORM1_VII_A_STATE_COOP_OTHER", day, officeId).amount) + .add(figureFor("FORM1_VII_B_DCCB_OTHER", day, officeId).amount) + .add(figureFor("FORM1_VII_C_GOVT_SECURITIES", day, officeId).amount) + .add(figureFor("FORMA_SLR_GOLD", day, officeId).amount) + .add(figureFor("SLR_INVESTMENTS", day, officeId).amount); + + final BigDecimal crrSurplus = crrHeld.subtract(crrRequired); + final BigDecimal slrSurplus = slrHeld.subtract(slrRequired); + if (crrSurplus.compareTo(BigDecimal.ZERO) < 0) { + crrShortDays++; + } + if (slrSurplus.compareTo(BigDecimal.ZERO) < 0) { + slrShortDays++; + } + + crrTotal = crrTotal.add(crrHeld); + slrTotal = slrTotal.add(slrHeld); + if (crrLowest == null || crrHeld.compareTo(crrLowest) < 0) { + crrLowest = crrHeld; + crrLowestOn = day; + } + if (slrLowest == null || slrHeld.compareTo(slrLowest) < 0) { + slrLowest = slrHeld; + slrLowestOn = day; + } + + final Map row = new LinkedHashMap<>(); + row.put("date", day.toString()); + row.put("baseDate", formOneBaseDate(day).toString()); + // The liabilities the day was charged on, stated rather than left to be divided back out + // of the requirement: the CIMS daily sheets carry it as a column of its own, and on a day + // whose rate is unset the requirement is zero, which no division recovers a base from. + row.put("baseLiabilities", base); + // The day's OWN liabilities, which the base above does not show. The base holds still + // for a whole fortnight, so a table carrying only it cannot say whether the bank's + // liabilities rose or fell on any particular day - every row of the fortnight reads the + // same. This moves daily, and the gap between the two is the lag the rules build in. + row.put("ownLiabilities", formOneNetLiabilities(day, officeId)); + row.put("crrRequired", crrRequired); + row.put("crrMaintained", crrHeld); + row.put("crrSurplus", crrSurplus); + row.put("slrRequired", slrRequired); + row.put("slrMaintained", slrHeld); + row.put("slrSurplus", slrSurplus); + days.add(row); + } + + final Map out = new LinkedHashMap<>(); + out.put("from", from.toString()); + out.put("to", to.toString()); + out.put("days", days); + out.put("dayCount", days.size()); + final BigDecimal count = BigDecimal.valueOf(Math.max(days.size(), 1)); + out.put("crrAverage", crrTotal.divide(count, 2, RoundingMode.HALF_UP)); + out.put("slrAverage", slrTotal.divide(count, 2, RoundingMode.HALF_UP)); + out.put("crrLowest", crrLowest == null ? BigDecimal.ZERO : crrLowest); + out.put("crrLowestOn", crrLowestOn == null ? null : crrLowestOn.toString()); + out.put("slrLowest", slrLowest == null ? BigDecimal.ZERO : slrLowest); + out.put("slrLowestOn", slrLowestOn == null ? null : slrLowestOn.toString()); + // Counted rather than left for a reader to spot: a single day short is a breach, and on a + // table of thirty rows it is exactly what the eye skips. + out.put("crrShortDays", crrShortDays); + out.put("slrShortDays", slrShortDays); + return out; + } + private BigDecimal formOnePartBRequired(final LocalDate date, final Long officeId) { final Rate crr = rateInForce("CRR", date); if (crr == null) { @@ -1433,23 +1607,47 @@ public class RbiReserveReturnApiResource { } /** - * The date both ratios are charged on: the last Friday of the SECOND PRECEDING fortnight. + * The base date a column's ratios are charged on. * *

- * Counted in fortnights, not in weeks. For a column closing 15 August the fortnight it belongs - * to is 1-15 August, the first preceding one is 16-31 July, and the second is 1-15 July - so - * the base falls in the first half of July, roughly six weeks back rather than the four a - * straight subtraction would give. Reading the rate off a date a fortnight too late would - * report a requirement the bank never had time to meet, which is the whole point of the lag. + * Taken from the list the caller supplied, so a base entered by hand reaches every part of the + * form rather than only the column heading. Where no list was given - or it is shorter than the + * columns - the statutory rule applies. + */ + private LocalDate baseFor(final List bases, final List dates, final int index) { + if (bases != null && index < bases.size() && bases.get(index) != null) { + return bases.get(index); + } + return formOneBaseDate(dates.get(index)); + } + + /** + * The date a column's ratios are charged on: the previous month's matching fortnight close. * *

- * Within that fortnight the form takes its last Friday, shifted off a holiday the same way - * every other reporting date is. + * Which fortnight the column falls in settles it, not the day it carries. The 1st to the 15th + * charge on the 15th of the previous month; anything later charges on the previous month's END, + * whatever day that is. So 30 September charges on 31 August - and so does 20 September, since + * both sit in the second fortnight and the second fortnight closes with the month. + * + *

+ * Keyed on the fortnight rather than on the day number because the two closes are not the same + * kind of date: the 15th is fixed, the month end moves between 28 and 31. Matching day for day + * would charge 30 September on 30 August, a date that closes nothing. + * + *

+ * RBI's own wording is "the last Friday of the second preceding fortnight", and that rule has + * been tried here twice. Both times it was taken out again for the same reason: it puts the base + * on a weekday that moves and roughly six weeks back, and the dates it produces do not match the + * returns this bank has already filed by hand. The fortnight closes this bank files on are the + * 15th and the month end. Where a particular return needs some other base, it can be entered by + * hand through {@code baseFor} above, which is why that path exists. */ private LocalDate formOneBaseDate(final LocalDate columnDate) { - final LocalDate ownClose = fortnightCloseOnOrAfter(columnDate); - final LocalDate secondPrecedingClose = fortnightCloseBefore(fortnightCloseBefore(ownClose)); - return precedingWorkingDay(reportingFridayOnOrBefore(secondPrecedingClose)); + final LocalDate previousMonthEnd = columnDate.withDayOfMonth(1).minusDays(1); + // Which fortnight the column falls in settles the base, not the day it carries. The first + // fortnight closes on the 15th; everything after it closes on the month end. + return columnDate.getDayOfMonth() <= 15 ? previousMonthEnd.withDayOfMonth(15) : previousMonthEnd; } /** @@ -1579,7 +1777,10 @@ public class RbiReserveReturnApiResource { final LocalDate previousClose = requested.withDayOfMonth(1).minusDays(1); final LocalDate base = formOneBaseDate(currentClose); - final List columnDates = List.of(previousClose, currentClose, base); + // Two columns, as on Form 1. The base is stated against the lines it settles rather than + // given a column, where its balances would read as a third period being reported. + final List columnDates = List.of(previousClose, currentClose); + final List baseDates = List.of(formOneBaseDate(previousClose), base); final Map response = new LinkedHashMap<>(); response.put("formCode", "FORM_9_STATUTORY"); @@ -1590,6 +1791,7 @@ public class RbiReserveReturnApiResource { response.put("monthEnd", nominalMonthEnd.toString()); response.put("monthComplete", monthComplete); response.put("columns", columnDates.stream().map(LocalDate::toString).toList()); + response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList()); final Rate crr = rateInForce("CRR", currentClose); final Rate slr = rateInForce("SLR", currentClose); @@ -1599,8 +1801,11 @@ public class RbiReserveReturnApiResource { // Part A establishes the liabilities both ratios are charged on, so it is needed here even // though only section 24 is settled below: without it the required amount at XI is a figure // with nothing behind it. - response.put("partA", formOnePartA(columnDates, officeId, crr, slr)); - response.put("partC", formOnePartC(columnDates, officeId, slr)); + response.put("partA", formOnePartA(columnDates, baseDates, officeId, crr, slr)); + response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr)); + // Every day of the month, as on Form 1. Section 24 is reckoned over the period too, so a + // month-end position that passes says nothing about the days behind it. + response.put("daily", formOneDaily(currentClose.withDayOfMonth(1), currentClose, officeId, crr, slr)); final List unmet = new ArrayList<>(); if (slr == null) { @@ -1620,9 +1825,9 @@ public class RbiReserveReturnApiResource { // As on Form 1: a nil base makes the requirement nil, and the form then reads the same as a // bank with nothing to maintain. Said with the date so the nil can be read for what it is. if (formOneNetLiabilities(base, officeId).compareTo(BigDecimal.ZERO) == 0) { - unmet.add("The ratio is charged on the net liabilities of " + base + " — the last Friday " - + "of the second preceding fortnight — and the ledger holds none on that date. " - + "The required amount is therefore nil, because the base is nil."); + unmet.add("The ratio is charged on the net liabilities of " + base + + ", and the ledger holds none on that date. The required amount is therefore " + + "nil, because the base is nil."); } response.put("unmetRequirements", unmet); return gson.toJson(response); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java index 3a8f0dea2..0131fc00e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java @@ -95,7 +95,9 @@ public class RbiFormAAutoSaveService { // No column dates passed: today IS a fortnight close - the check above returned // otherwise - so the form derives its own pair from it, which is the pair an unattended // run should file. A filer needing a different period enters both dates on the screen. - report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null), + // No base dates passed either: an unattended run files the base the rules fix, which is + // what a saved return should carry. A filer needing a different base enters it on screen. + report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null, null, null), JsonObject.class); } catch (final RuntimeException e) { // A return that cannot be computed is not an error worth failing the batch over - an diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index 22332b375..937c3eb54 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -580,7 +580,7 @@ public class SavingsDailyInterestService { final BigDecimal balance = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount - WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount ELSE 0 END), 0) FROM m_savings_account_transaction t WHERE t.savings_account_id = ? @@ -597,7 +597,7 @@ public class SavingsDailyInterestService { SELECT t.transaction_date AS d, SUM(CASE WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount - WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount ELSE 0 END) AS net FROM m_savings_account_transaction t WHERE t.savings_account_id = ? @@ -613,7 +613,7 @@ public class SavingsDailyInterestService { final BigDecimal movement = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount - WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount ELSE 0 END), 0) FROM m_savings_account_transaction t WHERE t.savings_account_id = ? diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 80ab253d5..c6ba74a3f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -79,6 +79,7 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData; import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; +import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -103,6 +104,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; private final FromJsonHelper fromApiJsonHelper; private final JdbcTemplate jdbcTemplate; + private final SavingsInterestTdsService savingsInterestTdsService; @Transactional @Override @@ -277,9 +279,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi updateAlreadyPostedTransactions(existingTransactionIds, account); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer); } else { + // Paid out in cash or to a GL, so the interest never reaches savings: TDS is tracked and taken here. + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.close(user, command, changes); @@ -400,10 +406,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.updateClosedStatus(); account.updateOnAccountClosureStatus(onClosureType); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); updateAlreadyPostedTransactions(existingTransactionIds, account); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } // if(!processMaturityInstructionOnly) @@ -483,9 +492,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.close(user, command, changes); @@ -855,6 +867,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi updateAlreadyPostedTransactions(existingTransactionIds, account); } else if (closureType.isTransferToGl()) { final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName); + // Taken before the proceeds are read, so the GL receives the payout net of TDS. + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + "GL"); final BigDecimal proceeds = account.getAccountBalance(); // Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer // account rather than cash/vault -- nothing is paid over the counter here. The offset below then @@ -865,15 +880,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi isRegularTransaction, isInternalTransfer); savingsTransactionId = withdrawal.getId(); updateAlreadyPostedTransactions(existingTransactionIds, account); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate, command.stringValueOfParameterNamed(transferDescriptionParamName)); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + "CASH"); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); // handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already // accounted for or the postJournalEntries() call below posts it a second time. updateAlreadyPostedTransactions(existingTransactionIds, account); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.prematureClosure(user, command, changes); @@ -1134,10 +1153,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi } } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); updateAlreadyPostedTransactions(existingTransactionIds, account); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); if (alreadyMatured) { account.close(user, command, changes); @@ -1195,9 +1217,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(closureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.prematureClosure(user, command, changes); @@ -1222,6 +1247,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData); } + /** How a closure that does not go to savings paid out, for the TDS tracking record. */ + private static String directPayoutKind(final DepositAccountOnClosureType closureType) { + return closureType != null && closureType.isTransferToGl() ? "GL" : "CASH"; + } + private void updateAlreadyPostedTransactions(final Set existingTransactionIds, final SavingsAccount savingsAccount) { // accountTransfersWritePlatformService.transferFunds() already posts accounting for the // withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index 91a0947b6..c2532e1f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -281,7 +281,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe + "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, " + "m.dd_limit_amount " + "from m_sponsor_dd_gl_mapping m " - + "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id " + // Left join: DD Payable moved to the sponsor bank (0570) and became nullable here (0573), so + // an inner join dropped the whole row and the settings screen reopened blank. + + "left join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id " + "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id " + "left join acc_gl_account cash on cash.id = m.cash_gl_account_id " + "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id " @@ -310,7 +312,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe final Long id = rs.getLong("id"); final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null; final String sponsorBankName = rs.getString("sponsor_bank_name"); - final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id"); + final Long ddPayableGlAccountId = rs.getObject("dd_payable_gl_account_id") != null ? rs.getLong("dd_payable_gl_account_id") : null; final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name"); final String ddPayableGlCode = rs.getString("dd_payable_gl_code"); final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java index d3dff7274..29df91a4c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java @@ -52,10 +52,10 @@ public class TdsInterestApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Deposit interest per customer for a financial year", description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.") - public String retrieve(@QueryParam("financialYear") final Integer financialYear) { + public String retrieve(@QueryParam("financialYear") final Integer financialYear, @QueryParam("clientId") final Long clientId) { this.context.authenticatedUser(); final List deductees = this.readPlatformService - .retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear()); + .retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId); return this.toApiJsonSerializer.serialize(deductees); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsRecordsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsRecordsApiResource.java new file mode 100644 index 000000000..8591ca7b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsRecordsApiResource.java @@ -0,0 +1,490 @@ +/** + * 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.tds.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.tds.service.TdsSql; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * The two TDS records the bank keeps by hand: the declarations customers give at the counter, and the + * challans Head Office remits the deducted tax with. + * + *

+ * Both feed the deduction and the reports directly. A declaration stops the deduction from the day it + * is accepted; a challan is what the remittance report reconciles the deducted tax against. + */ +@Path("/v1/tds") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class TdsRecordsApiResource { + + private static final String DECLARATION = "TDSDECLARATION"; + private static final String CHALLAN = "TDSCHALLAN"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── declarations ─────────────────────────── + + /** A customer's declarations, every year and revoked ones included, newest year first. */ + @GET + @Path("declarations") + public String declarations(@QueryParam("clientId") final Long clientId, @QueryParam("fy") final String fy) { + final AppUser user = this.context.authenticatedUser(); + if (clientId == null && fy == null) { + throw new PlatformDataIntegrityException("error.msg.tds.declarations.filter.required", "Give a clientId or a fy."); + } + final List params = new ArrayList<>(); + final StringBuilder where = new StringBuilder(" WHERE o.hierarchy LIKE ?"); + params.add(user.getOffice().getHierarchy() + "%"); + if (clientId != null) { + where.append(" AND d.client_id = ?"); + params.add(clientId); + } + if (fy != null) { + where.append(" AND d.fy_label = ?"); + params.add(fy); + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.id, d.client_id, c.display_name AS client_name, d.fy_label, d.form_type, d.uin, d.accepted_on, + d.estimated_income, d.is_revoked, d.revoked_on, d.revoke_reason + FROM m_tds_declaration d + JOIN m_client c ON c.id = d.client_id + JOIN m_office o ON o.id = c.office_id + """ + where + " ORDER BY d.fy_label DESC, d.accepted_on DESC", params.toArray()); + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("clientId", r.get("client_id")); + o.put("clientName", r.get("client_name")); + o.put("fy", r.get("fy_label")); + o.put("formType", r.get("form_type")); + o.put("uin", r.get("uin")); + o.put("acceptedOn", str(r.get("accepted_on"))); + o.put("estimatedIncome", r.get("estimated_income")); + o.put("revoked", r.get("is_revoked")); + o.put("revokedOn", str(r.get("revoked_on"))); + o.put("revokeReason", r.get("revoke_reason")); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Records a declaration. One live declaration per customer per year; the form must be the one the + * year's configuration names (Form 121 from 2026-27, 15G/15H before), the date must fall in the year + * and not in the future, and the customer must have a PAN - a declaration without one is invalid. + */ + @POST + @Path("declarations") + @Consumes({ MediaType.APPLICATION_JSON }) + @Transactional + public String createDeclaration(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("CREATE_" + DECLARATION); + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + + final Long clientId = requiredLong(root, "clientId"); + assertInScope(user, clientId); + final String fy = requiredText(root, "fy"); + final String formType = requiredText(root, "formType").toUpperCase(); + final LocalDate acceptedOn = requiredDate(root, "acceptedOn"); + final String uin = optionalText(root, "uin"); + final BigDecimal estimatedIncome = root.has("estimatedIncome") && !root.get("estimatedIncome").isJsonNull() + && !root.get("estimatedIncome").getAsString().isBlank() ? root.get("estimatedIncome").getAsBigDecimal() : null; + + final List> cfg = jdbcTemplate.queryForList("SELECT declaration_form FROM m_tds_fy_config WHERE fy_label = ?", + fy); + if (cfg.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.fy.not.configured", + "Financial year " + fy + " is not set up under Organization › TDS Configuration."); + } + final String yearForm = (String) cfg.get(0).get("declaration_form"); + final boolean merged = "121".equals(yearForm); + if (merged ? !"121".equals(formType) : !("15G".equals(formType) || "15H".equals(formType))) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.year", + merged ? "From " + fy + " the declaration is Form 121." : "For " + fy + " the declaration is Form 15G or 15H."); + } + + final int startYear = Integer.parseInt(fy.substring(0, 4)); + final LocalDate fyStart = LocalDate.of(startYear, 4, 1); + final LocalDate fyEnd = LocalDate.of(startYear + 1, 3, 31); + if (acceptedOn.isBefore(fyStart) || acceptedOn.isAfter(fyEnd)) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.outside.year", + "The acceptance date must fall inside " + fy + "."); + } + if (acceptedOn.isAfter(DateUtils.getBusinessLocalDate())) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.in.future", + "A declaration cannot be accepted on a future date."); + } + + final Map person = jdbcTemplate.queryForMap(""" + SELECT c.date_of_birth, + (SELECT k.pan_number FROM m_client_kyc k + WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + ORDER BY k.id DESC LIMIT 1) AS pan + FROM m_client c WHERE c.id = ? + """, clientId); + if (!TdsSql.isWellFormedPan((String) person.get("pan"))) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.pan.required", + "A declaration is valid only with the customer's PAN. Record a PAN on the KYC first."); + } + if (!merged) { + final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth"); + final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), fyEnd); + if (senior != "15H".equals(formType)) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.age", + senior ? "This customer is 60 or over in " + fy + ", so the form is 15H." : "This customer is under 60 in " + fy + + ", so the form is 15G."); + } + } + + final Integer live = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_tds_declaration WHERE client_id = ? AND fy_label = ? AND is_revoked = false", Integer.class, + clientId, fy); + if (live != null && live > 0) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.duplicate", + "This customer already has a declaration on record for " + fy + ". Revoke it first to replace it."); + } + + jdbcTemplate.update(""" + INSERT INTO m_tds_declaration (client_id, fy_label, form_type, uin, accepted_on, estimated_income, + is_revoked, created_by, created_on) + VALUES (?, ?, ?, ?, ?, ?, false, ?, now()) + """, clientId, fy, formType, uin, acceptedOn, estimatedIncome, user.getId()); + return declarations(clientId, null); + } + + /** + * Withdraws a declaration from a date. Interest credited from then on is deducted again; what was + * paid gross while it stood stays paid gross, because it was lawful at the time. + */ + @POST + @Path("declarations/{id}/revoke") + @Consumes({ MediaType.APPLICATION_JSON }) + @Transactional + public String revokeDeclaration(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("UPDATE_" + DECLARATION); + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + final List owner = jdbcTemplate.queryForList("SELECT client_id FROM m_tds_declaration WHERE id = ? AND is_revoked = false", + Long.class, id); + if (owner.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.not.found", "No live declaration " + id + "."); + } + assertInScope(user, owner.get(0)); + final String reason = requiredText(root, "reason"); + jdbcTemplate.update(""" + UPDATE m_tds_declaration + SET is_revoked = true, revoked_on = ?, revoke_reason = ?, revoked_by = ? + WHERE id = ? + """, DateUtils.getBusinessLocalDate(), reason, user.getId(), id); + return declarations(owner.get(0), null); + } + + // ─────────────────────────── challans ─────────────────────────── + + /** Every challan entered for the year, reversed ones included so the trail stays visible. */ + @GET + @Path("challans") + public String challans(@QueryParam("fy") final String fy) { + this.context.authenticatedUser(); + final List> rows = jdbcTemplate.queryForList(""" + SELECT ch.id, ch.fy_label, ch.deduction_month, ch.amount, ch.challan_no, ch.bsr_code, ch.deposited_on, + ch.paid_from_gl_account_id, g.gl_code, g.name AS gl_name, ch.journal_transaction_id, ch.remarks, + ch.is_reversed + FROM m_tds_challan ch + JOIN acc_gl_account g ON g.id = ch.paid_from_gl_account_id + WHERE ch.fy_label = ? + ORDER BY ch.deduction_month, ch.deposited_on, ch.id + """, fy); + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("fy", r.get("fy_label")); + o.put("month", r.get("deduction_month")); + o.put("amount", r.get("amount")); + o.put("challanNo", r.get("challan_no")); + o.put("bsrCode", r.get("bsr_code")); + o.put("depositedOn", str(r.get("deposited_on"))); + o.put("paidFromGlAccountId", r.get("paid_from_gl_account_id")); + o.put("paidFrom", r.get("gl_code") + " · " + r.get("gl_name")); + o.put("journalTransactionId", r.get("journal_transaction_id")); + o.put("remarks", r.get("remarks")); + o.put("reversed", r.get("is_reversed")); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Tax actually deducted, month by month, for the year: the TDS debits on savings accounts for FD/RD + * interest received there. This is what the challans must add up to. + */ + @GET + @Path("deducted") + public String deducted(@QueryParam("fy") final String fy) { + this.context.authenticatedUser(); + requireFy(fy); + final List> rows = jdbcTemplate.queryForList(""" + SELECT TO_CHAR(d.deducted_on, 'YYYY-MM') AS month, SUM(d.amount) AS amount, COUNT(*) AS postings + FROM m_tds_deduction d + JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false + WHERE d.fy_label = ? + GROUP BY 1 ORDER BY 1 + """, fy); + // The business date, not the wall clock: whether a challan is overdue is judged on the day the + // books are on. + final Map out = new LinkedHashMap<>(); + out.put("asAt", DateUtils.getBusinessLocalDate().toString()); + out.put("months", rows); + return gson.toJson(out); + } + + /** Accounts the tax can be paid out of: active, postable asset accounts (bank, cash). */ + @GET + @Path("challans/template") + public String challanTemplate() { + this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + CHALLAN); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, gl_code AS "glCode", name FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 AND classification_enum = 1 + ORDER BY gl_code + """); + final Map out = new LinkedHashMap<>(); + out.put("paidFromOptions", accounts); + return gson.toJson(out); + } + + /** + * Records a remittance and posts it: Dr TDS payable / Cr the account paid from, at Head Office. + * Only Head Office remits - the bank files one return under one TAN. + */ + @POST + @Path("challans") + @Consumes({ MediaType.APPLICATION_JSON }) + @Transactional + public String createChallan(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("CREATE_" + CHALLAN); + assertHeadOffice(user); + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + + final String fy = requireFy(requiredText(root, "fy")); + final String month = requiredText(root, "month"); + if (!month.matches("\\d{4}-\\d{2}")) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.month.invalid", "Month must look like 2026-06."); + } + final int startYear = Integer.parseInt(fy.substring(0, 4)); + final LocalDate monthStart = LocalDate.parse(month + "-01"); + if (monthStart.isBefore(LocalDate.of(startYear, 4, 1)) || monthStart.isAfter(LocalDate.of(startYear + 1, 3, 1))) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.month.outside.year", "The month must fall inside " + fy + "."); + } + final BigDecimal amount = root.get("amount").getAsBigDecimal(); + if (amount.signum() <= 0) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.amount.invalid", "Amount must be more than zero."); + } + final String challanNo = requiredText(root, "challanNo"); + final String bsrCode = requiredText(root, "bsrCode"); + if (!bsrCode.matches("\\d{7}")) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.bsr.invalid", "BSR code is 7 digits."); + } + final LocalDate depositedOn = requiredDate(root, "depositedOn"); + if (depositedOn.isAfter(DateUtils.getBusinessLocalDate())) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.date.in.future", "Deposit date cannot be in the future."); + } + final Long paidFrom = requiredLong(root, "paidFromGlAccountId"); + final String remarks = optionalText(root, "remarks"); + + final Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_tds_challan WHERE challan_no = ? AND bsr_code = ? AND deposited_on = ? AND is_reversed = false", + Integer.class, challanNo, bsrCode, depositedOn); + if (dup != null && dup > 0) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.duplicate", "This challan is already on record."); + } + + final Long tdsPayable = tdsPayableGl(); + final long officeId = user.getOffice().getId(); + final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class); + final String txnId = "TDSCH-" + UUID.randomUUID().toString().substring(0, 18); + final String narration = "TDS remitted for " + month + " · challan " + challanNo + " · BSR " + bsrCode; + journalEntry(officeId, currency, tdsPayable, depositedOn, amount, true, txnId, narration, user.getId()); + journalEntry(officeId, currency, paidFrom, depositedOn, amount, false, txnId, narration, user.getId()); + + jdbcTemplate.update(""" + INSERT INTO m_tds_challan (fy_label, deduction_month, amount, challan_no, bsr_code, deposited_on, + paid_from_gl_account_id, office_id, journal_transaction_id, remarks, + is_reversed, created_by, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, now()) + """, fy, month, amount, challanNo, bsrCode, depositedOn, paidFrom, officeId, txnId, remarks, user.getId()); + return challans(fy); + } + + /** Undoes a challan entered in error: the entry is reversed with an opposite one, the row kept. */ + @POST + @Path("challans/{id}/reverse") + @Transactional + public String reverseChallan(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("UPDATE_" + CHALLAN); + assertHeadOffice(user); + final List> rows = jdbcTemplate.queryForList( + "SELECT fy_label, amount, paid_from_gl_account_id, office_id, journal_transaction_id, challan_no FROM m_tds_challan" + + " WHERE id = ? AND is_reversed = false", + id); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.not.found", "No live challan " + id + "."); + } + final Map ch = rows.get(0); + final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final String txnId = "TDSCHR-" + UUID.randomUUID().toString().substring(0, 17); + final String narration = "Reversal of TDS challan " + ch.get("challan_no"); + final long officeId = ((Number) ch.get("office_id")).longValue(); + final BigDecimal amount = (BigDecimal) ch.get("amount"); + journalEntry(officeId, currency, tdsPayableGl(), today, amount, false, txnId, narration, user.getId()); + journalEntry(officeId, currency, ((Number) ch.get("paid_from_gl_account_id")).longValue(), today, amount, true, txnId, narration, + user.getId()); + jdbcTemplate.update("UPDATE m_tds_challan SET is_reversed = true WHERE id = ?", id); + return challans((String) ch.get("fy_label")); + } + + // ─────────────────────────── helpers ─────────────────────────── + + /** Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. */ + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, + final BigDecimal amount, final boolean debit, final String reference, final String narration, final Long userId) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); + jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, + amount, description, entity_type_enum, entity_id, is_running_balance_calculated, + reversed, manual_entry, office_running_balance, organization_running_balance, + created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, + last_modified_on_utc, submitted_on_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, false, false, true, 0, 0, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) + """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, userId, userId, date); + } + + private Long tdsPayableGl() { + final List gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'", + Long.class); + if (gl.isEmpty() || gl.get(0) == null) { + throw new PlatformDataIntegrityException("error.msg.tds.payable.gl.not.configured", + "No TDS payable account is set. Choose one under Organization › TDS Configuration."); + } + return gl.get(0); + } + + private void assertHeadOffice(final AppUser user) { + if (user.getOffice().getParent() != null) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.head.office.only", + "TDS is remitted by Head Office. A branch login cannot record a challan."); + } + } + + /** A user may act only on customers of their own office or the offices below it. */ + private void assertInScope(final AppUser user, final Long clientId) { + final Integer n = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_client c JOIN m_office o ON o.id = c.office_id + WHERE c.id = ? AND o.hierarchy LIKE ? + """, Integer.class, clientId, user.getOffice().getHierarchy() + "%"); + if (n == null || n == 0) { + throw new PlatformDataIntegrityException("error.msg.tds.client.out.of.scope", + "Customer " + clientId + " does not belong to your office."); + } + } + + private static String requireFy(final String fy) { + if (fy == null || !fy.matches("\\d{4}-\\d{2}")) { + throw new PlatformDataIntegrityException("error.msg.tds.fy.invalid", "Financial year must look like 2026-27."); + } + return fy; + } + + private static String str(final Object v) { + return v == null ? null : String.valueOf(v); + } + + private static String optionalText(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull()) { + return null; + } + final String v = root.get(name).getAsString().trim(); + return v.isEmpty() ? null : v; + } + + private static String requiredText(final JsonObject root, final String name) { + final String v = optionalText(root, name); + if (v == null) { + throw new PlatformDataIntegrityException("error.msg.tds." + name + ".required", name + " is required."); + } + return v; + } + + private static Long requiredLong(final JsonObject root, final String name) { + try { + return Long.valueOf(requiredText(root, name)); + } catch (NumberFormatException e) { + throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a number."); + } + } + + /** yyyy-MM-dd. */ + private static LocalDate requiredDate(final JsonObject root, final String name) { + try { + return LocalDate.parse(requiredText(root, name)); + } catch (DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a date like 2026-09-15."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeclarationData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeclarationData.java new file mode 100644 index 000000000..487a54a96 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeclarationData.java @@ -0,0 +1,27 @@ +/** + * 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.tds.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; + +/** A 15G / 15H / Form 121 declaration: one customer, one financial year, from the day it was accepted. */ +public record TdsDeclarationData(Long id, Long clientId, String fy, String formType, String uin, LocalDate acceptedOn, + BigDecimal estimatedIncome, boolean revoked, LocalDate revokedOn, String revokeReason) implements Serializable {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java index 57819017f..5cb684bcb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java @@ -19,12 +19,13 @@ package org.apache.fineract.portfolio.tds.data; import java.io.Serializable; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; /** - * A customer whose deposit interest the branch must consider for deduction under section 194A, - * together with the accounts and the interest actually credited to them. + * A customer whose deposit interest the branch must consider for deduction, together with the + * accounts and the interest actually credited to them. * *

* The section bites on time deposits, so savings accounts are carried here as well but marked by @@ -32,23 +33,31 @@ import java.util.List; * separately. Sending only the fixed and recurring deposits would hide from the branch that the * customer holds a savings balance at all, which is the first thing an officer checks when a * customer disputes a deduction. + * + *

+ * The customer here is the deductee: a minor's deposit is listed under the guardian and a + * joint deposit under its primary holder, because that is whose PAN the tax is deducted against. */ public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, - String branchName, String form15Type, List accounts) implements Serializable { + String branchName, List declarations, List accounts) implements Serializable { /** - * One deposit account and the interest posted to it. + * One deposit account, the interest posted to it and the TDS actually deducted from it. * * @param depositTypeCode * Fineract's own code - {@code depositAccountType.fixedDeposit}, * {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether * the charge applies at all, so it is sent as the platform states it rather than as * a flag this report worked out for itself. + * @param clientId + * the deductee, which is the account holder unless the account is a minor's or joint + * @param holderClientId + * the account's own holder, shown when it differs from the deductee */ - public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus, - Long clientId, LocalDate openedOn, List credits) implements Serializable {} + public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId, + String holderName, String accountStatus, LocalDate openedOn, List credits, + List deductions) implements Serializable {} - /** One interest posting: the date it was credited and the amount. */ - public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo) - implements Serializable {} + /** One interest posting, or one TDS deduction: the date and the amount. */ + public record TdsInterestCreditData(LocalDate date, BigDecimal amount, String accountNo) implements Serializable {} } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java new file mode 100644 index 000000000..be76fd39d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java @@ -0,0 +1,389 @@ +/** + * 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.tds.service; + +import jakarta.persistence.EntityManager; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.portfolio.account.domain.AccountTransferType; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * TDS on FD/RD interest, tracked and deducted where the interest is received. + * + *

+ * Normally that is the customer's savings account: every time money moves from a fixed or recurring + * deposit into savings, the interest in it is recorded against the deductee (see + * {@link TdsSql#deducteeOf}) and any TDS due is debited from that savings account. The periodic + * interest sweep is all interest; a maturity or closure payout is principal and interest together, so + * only the interest not already received is counted. The FD and RD are not debited. + * + *

+ * When a maturity or closure pays out in cash, by cheque or to a GL account instead, the interest never + * reaches savings, so it is tracked at the payout and the TDS comes out of the payout itself. A full + * reinvestment pays nothing out and is not taxed here. + * + *

+ * The rules, all for one financial year and one deductee: + *

    + *
  1. The FD/RD interest received, across every branch, is added up. The savings account's own interest + * is not - it is outside the charge.
  2. + *
  3. A declaration shields the interest received from the day it is accepted. Interest received + * before it stays liable.
  4. + *
  5. Nothing is deducted while the total stays within the threshold (senior or general).
  6. + *
  7. Once over it, the whole year's interest is liable - not merely the excess - at the PAN rate, or + * the higher rate without a valid PAN.
  8. + *
  9. What is deducted now is that liability less everything already deducted this year, so the credit + * that crosses the threshold catches up the earlier ones, and nothing is ever deducted twice.
  10. + *
+ * + *

+ * The tax is posted as a withhold-tax transaction through a tax component whose credit account is the + * TDS payable account set on Organization › TDS Configuration, so the passbook shows it and the + * account's own accounting writes Dr deposit liability / Cr TDS payable. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SavingsInterestTdsService { + + /** The tax component every TDS deduction is booked through. Kept in step with the GL setting. */ + public static final String TDS_COMPONENT_NAME = "TDS on deposit interest"; + + private final JdbcTemplate jdbcTemplate; + private final TaxComponentRepository taxComponentRepository; + private final EntityManager entityManager; + private final SavingsAccountRepositoryWrapper savingsAccountRepository; + private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; + private final JournalEntryWritePlatformService journalEntryWritePlatformService; + + /** + * Called once a savings-to-savings transfer has been saved. + * + * @param deposit + * the credit on the receiving savings account + */ + public void onTransfer(final SavingsAccount from, final SavingsAccount to, final SavingsAccountTransaction deposit, + final AccountTransferType transferType) { + if (from == null || to == null || deposit == null || deposit.getId() == null || !isTimeDeposit(from)) { + return; + } + final DepositAccountType toType = to.depositAccountType(); + if (toType == null || !toType.isSavingsDeposit()) { + return; + } + + final boolean sweep = transferType != null && transferType.isInterestTransfer(); + final BigDecimal interest = sweep ? deposit.getAmount() + : interestInPayout(from.getId(), postedFromDatabase(from.getId()), deposit.getAmount()); + if (interest.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + + final LocalDate date = deposit.getTransactionDate(); + final Year year = Year.of(date); + final Long deductee = deducteeOf(from, year); + if (deductee == null) { + return; + } + + final Long receiptId = insertReceipt(deductee, to.getId(), deposit.getId(), from.getId(), sweep ? "INTEREST" : "PAYOUT", year, + date, interest); + + final Due due = dueFor(deductee, year, date, BigDecimal.ZERO); + if (due == null) { + return; + } + final Set existingIds = new HashSet<>(to.findExistingTransactionIds()); + final Set existingReversedIds = new HashSet<>(to.findExistingReversedTransactionIds()); + final SavingsAccountTransaction tds = to.deductTds(date, due.amount(), Map.of(due.component(), due.amount())); + // Saved on its own first, as Fineract does for every new transaction: saving only the account + // merges it, and the id lands on the merged copy - this instance stays null, which broke both + // the deduction row and the journal entry's transaction reference. + this.savingsAccountTransactionRepository.saveAndFlush(tds); + this.savingsAccountRepository.saveAndFlush(to); + this.journalEntryWritePlatformService.createJournalEntriesForSavings( + to.deriveAccountingBridgeData(to.getCurrency().getCode(), existingIds, existingReversedIds, false, false)); + recordDeduction(deductee, to.getId(), tds.getId(), from.getId(), receiptId, year, date, due); + } + + /** + * Interest paid straight out of an FD/RD at maturity or closure - in cash, by cheque, or to a GL + * account - never reaches a savings account, so it is tracked here instead, and any TDS it makes due + * is taken out of the payout itself. + * + *

+ * Call just before the closing withdrawal is worked out: the deduction is added to the deposit, so a + * withdrawal of the account balance pays out what is left. The deposit is not saved here - the + * closing withdrawal saves it and posts the deduction's accounting along with its own. Then call + * {@link #recordDirectPayout}. + * + * @param kind + * CASH or GL, for the tracking record + * @return what to record once the withdrawal exists, or null when the payout carried no interest + */ + public DirectPayout deductOnDirectPayout(final SavingsAccount deposit, final LocalDate date, final String kind) { + if (deposit == null || deposit.getId() == null || date == null || !isTimeDeposit(deposit)) { + return null; + } + // From memory: the maturity or pre-closure interest posted moments ago is not saved yet. + BigDecimal posted = BigDecimal.ZERO; + for (final SavingsAccountTransaction t : deposit.getTransactions()) { + if (t.isInterestPostingAndNotReversed() && !t.isReversalTransaction()) { + posted = posted.add(t.getAmount()); + } + } + final BigDecimal interest = interestInPayout(deposit.getId(), posted, deposit.getAccountBalance()); + if (interest.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + final Year year = Year.of(date); + final Long deductee = deducteeOf(deposit, year); + if (deductee == null) { + return null; + } + final Due due = dueFor(deductee, year, date, interest); + final SavingsAccountTransaction tds = due == null ? null + : deposit.deductTds(date, due.amount(), Map.of(due.component(), due.amount())); + return new DirectPayout(deposit.getId(), deductee, year, date, interest, kind, due, tds); + } + + /** + * Records the tracking row and the deduction for a direct payout, once the closing withdrawal has been + * saved, and marks the deduction as already posted so the caller's own journal run does not post it a + * second time - the withdrawal posted it. + */ + public void recordDirectPayout(final DirectPayout payout, final SavingsAccountTransaction withdrawal, + final Set alreadyPostedTransactionIds) { + if (payout == null || withdrawal == null || withdrawal.getId() == null) { + return; + } + final Long receiptId = insertReceipt(payout.deductee(), payout.accountId(), withdrawal.getId(), payout.accountId(), payout.kind(), + payout.year(), payout.date(), payout.interest()); + if (payout.tds() != null && payout.tds().getId() != null) { + recordDeduction(payout.deductee(), payout.accountId(), payout.tds().getId(), payout.accountId(), receiptId, payout.year(), + payout.date(), payout.due()); + if (alreadyPostedTransactionIds != null) { + alreadyPostedTransactionIds.add(payout.tds().getId()); + } + } + } + + /** What {@link #deductOnDirectPayout} worked out, held until the withdrawal is saved. */ + public record DirectPayout(Long accountId, Long deductee, Year year, LocalDate date, BigDecimal interest, String kind, Due due, + SavingsAccountTransaction tds) {} + + /** TDS due now, and what it was worked out on. */ + public record Due(BigDecimal amount, BigDecimal ratePercent, BigDecimal liableInterest, TaxComponent component) {} + + /** One Indian financial year. */ + public record Year(LocalDate start, LocalDate end, String label) { + + static Year of(final LocalDate date) { + final int startYear = TdsSql.fyStartYear(date); + return new Year(LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31), TdsSql.fyLabel(startYear)); + } + } + + private static boolean isTimeDeposit(final SavingsAccount account) { + final DepositAccountType type = account.depositAccountType(); + return type != null && (type.isFixedDeposit() || type.isRecurringDeposit()); + } + + private Long deducteeOf(final SavingsAccount deposit, final Year year) { + return jdbcTemplate.queryForObject("SELECT " + TdsSql.deducteeOf("sa") + " FROM m_savings_account sa WHERE sa.id = ?", Long.class, + year.start(), deposit.getId()); + } + + private Long insertReceipt(final Long deductee, final Long receivingAccountId, final Long txnId, final Long sourceAccountId, + final String kind, final Year year, final LocalDate date, final BigDecimal interest) { + return jdbcTemplate.queryForObject(""" + INSERT INTO m_tds_interest_receipt (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, + kind, fy_label, received_on, interest_amount, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, now()) RETURNING id + """, Long.class, deductee, receivingAccountId, txnId, sourceAccountId, kind, year.label(), date, interest); + } + + private void recordDeduction(final Long deductee, final Long accountId, final Long txnId, final Long sourceAccountId, + final Long receiptId, final Year year, final LocalDate date, final Due due) { + jdbcTemplate.update(""" + INSERT INTO m_tds_deduction (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, receipt_id, + fy_label, deducted_on, amount, rate_percentage, interest_base, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + """, deductee, accountId, txnId, sourceAccountId, receiptId, year.label(), date, due.amount(), due.ratePercent(), + due.liableInterest()); + } + + /** + * The TDS due for the deductee now, or null when nothing is. + * + * @param extraInterest + * interest received on {@code date} that is not on record yet + */ + private Due dueFor(final Long deductee, final Year year, final LocalDate date, final BigDecimal extraInterest) { + final List> cfgRows = jdbcTemplate.queryForList(""" + SELECT threshold_general, threshold_senior, rate_pan_percentage, rate_no_pan_percentage, round_to + FROM m_tds_fy_config WHERE fy_label = ? + """, year.label()); + if (cfgRows.isEmpty()) { + // Tracked, but not deducted: the TDS screens show the same gap and the administrator fixes it there. + log.warn("No TDS configuration for FY {}; interest for client {} tracked but not taxed", year.label(), deductee); + return null; + } + final Map cfg = cfgRows.get(0); + + final List accepted = jdbcTemplate.queryForList(""" + SELECT accepted_on FROM m_tds_declaration + WHERE client_id = ? AND fy_label = ? AND is_revoked = false + ORDER BY accepted_on + """, LocalDate.class, deductee, year.label()); + final LocalDate shieldedFrom = accepted.isEmpty() ? year.end().plusDays(1) : accepted.get(0); + + final Map totals = jdbcTemplate.queryForMap(""" + SELECT COALESCE(SUM(r.interest_amount), 0) AS interest, + COALESCE(SUM(CASE WHEN r.received_on < ? THEN r.interest_amount END), 0) AS liable + FROM m_tds_interest_receipt r + JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false + WHERE r.deductee_client_id = ? AND r.fy_label = ? AND r.received_on <= ? + """, shieldedFrom, deductee, year.label(), date); + final BigDecimal yearInterest = ((BigDecimal) totals.get("interest")).add(extraInterest); + final BigDecimal liableInterest = ((BigDecimal) totals.get("liable")) + .add(date.isBefore(shieldedFrom) ? extraInterest : BigDecimal.ZERO); + final BigDecimal deducted = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(d.amount), 0) + FROM m_tds_deduction d + JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false + WHERE d.deductee_client_id = ? AND d.fy_label = ? + """, BigDecimal.class, deductee, year.label()); + + final Map person = jdbcTemplate.queryForMap(""" + SELECT c.date_of_birth, + (SELECT k.pan_number FROM m_client_kyc k + WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + ORDER BY k.id DESC LIMIT 1) AS pan + FROM m_client c WHERE c.id = ? + """, deductee); + final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth"); + final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), year.end()); + final BigDecimal threshold = (BigDecimal) cfg.get(senior ? "threshold_senior" : "threshold_general"); + if (yearInterest.compareTo(threshold) <= 0) { + return null; + } + + final boolean panOk = TdsSql.isWellFormedPan((String) person.get("pan")); + final BigDecimal ratePercent = (BigDecimal) cfg.get(panOk ? "rate_pan_percentage" : "rate_no_pan_percentage"); + final int roundTo = ((Number) cfg.get("round_to")).intValue(); + final BigDecimal liability = TdsSql.roundToMultiple(liableInterest.multiply(ratePercent).movePointLeft(2), roundTo); + final BigDecimal already = deducted == null ? BigDecimal.ZERO : deducted; + final BigDecimal amount = liability.subtract(already); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + log.info("TDS {} due for client {} (FY {}: interest {}, liability {}, already deducted {})", amount, deductee, year.label(), + yearInterest, liability, already); + return new Due(amount, ratePercent, liableInterest, tdsComponent(ratePercent)); + } + + private BigDecimal postedFromDatabase(final Long accountId) { + return jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false + """, BigDecimal.class, accountId, TdsSql.INTEREST_POSTING); + } + + /** + * The interest inside a maturity or closure payout: interest posted on the deposit that has not been + * received already - through the interest sweep or an earlier payout. Capped at the payout. + * + *

+ * On a premature closure the months already swept to savings stand as they were; only the interest + * for the days since is paid out now, and only that is counted. + */ + private BigDecimal interestInPayout(final Long sourceAccountId, final BigDecimal posted, final BigDecimal payout) { + final BigDecimal swept = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(att.amount), 0) + FROM m_account_transfer_transaction att + JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id + WHERE d.from_savings_account_id = ? AND d.transfer_type = ? AND att.is_reversed = false + """, BigDecimal.class, sourceAccountId, AccountTransferType.INTEREST_TRANSFER.getValue()); + final BigDecimal paidOut = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(r.interest_amount), 0) + FROM m_tds_interest_receipt r + JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false + WHERE r.source_account_id = ? AND r.kind <> 'INTEREST' + """, BigDecimal.class, sourceAccountId); + final BigDecimal unpaid = posted.subtract(swept).subtract(paidOut); + if (unpaid.compareTo(BigDecimal.ZERO) <= 0 || payout == null) { + return BigDecimal.ZERO; + } + return unpaid.min(payout); + } + + /** + * The tax component the deduction is booked through, created on first use and re-pointed whenever + * the TDS payable account on the configuration screen changes. + */ + private TaxComponent tdsComponent(final BigDecimal ratePercent) { + final List gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'", + Long.class); + if (gl.isEmpty() || gl.get(0) == null) { + throw new GeneralPlatformDomainRuleException("error.msg.tds.payable.gl.not.configured", + "TDS is due on this interest but no TDS payable account is set. Choose one under Organization › TDS Configuration."); + } + final Long glId = gl.get(0); + final List existing = jdbcTemplate.queryForList("SELECT id FROM m_tax_component WHERE name = ? ORDER BY id", Long.class, + TDS_COMPONENT_NAME); + final Long id; + boolean repointed = false; + if (existing.isEmpty()) { + id = jdbcTemplate.queryForObject(""" + INSERT INTO m_tax_component (name, percentage, credit_account_type_enum, credit_account_id, start_date, + createdby_id, created_date, lastmodifiedby_id, lastmodified_date) + SELECT ?, ?, 2, ?, DATE '2000-01-01', MIN(u.id), now(), MIN(u.id), now() FROM m_appuser u + RETURNING id + """, Long.class, TDS_COMPONENT_NAME, ratePercent, glId); + } else { + id = existing.get(0); + repointed = jdbcTemplate.update("UPDATE m_tax_component SET credit_account_type_enum = 2, credit_account_id = ? WHERE id = ?" + + " AND (credit_account_id IS DISTINCT FROM ? OR credit_account_type_enum IS DISTINCT FROM 2)", glId, id, glId) > 0; + } + final TaxComponent component = taxComponentRepository.findById(id).orElseThrow(); + // Written behind JPA's back, so a copy already in this session would still credit the old account. + if (repointed) { + entityManager.refresh(component); + } + return component; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java index 1576d8ec0..0a446a43d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java @@ -29,6 +29,8 @@ public interface TdsInterestReadPlatformService { * @param financialYear * the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to * 31 Mar 2027 + * @param clientId + * one customer only, or null for everyone in the user's scope */ - List retrieveForFinancialYear(int financialYear); + List retrieveForFinancialYear(int financialYear, Long clientId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java index 379a553a0..e6f2c30d1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java @@ -27,6 +27,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.tds.data.TdsDeclarationData; import org.apache.fineract.portfolio.tds.data.TdsDeducteeData; import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData; import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData; @@ -35,8 +37,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * Reads the interest the bank actually credited, so the deduction is worked out from the ledger - * rather than from a balance and a rate. + * Reads the interest the bank actually credited, and the TDS it actually deducted, so the register is + * worked out from the ledger rather than from a balance and a rate. */ @Service @RequiredArgsConstructor @@ -44,109 +46,150 @@ import org.springframework.transaction.annotation.Transactional; public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService { /** - * Interest posting - NOT a deposit, a withdrawal, a fee or a transfer. + * Every account, grouped under the client whose PAN carries its deduction, with what counts for TDS + * inside the year. * *

- * This single condition is what makes the figure an interest figure. A deposit is the - * customer's own money arriving and is not income; a withdrawal and a fee are not income - * either. Only what the bank itself credited as interest can be deducted against, so the - * transaction type is filtered here rather than being inferred later from an amount. - * - * @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING - */ - private static final int INTEREST_POSTING = 3; - - /** - * Every deposit the customer holds, with the interest credited to each inside the year. + * On a fixed or recurring deposit, "interest" is the FD/RD interest that reached the customer's + * savings account (m_tds_interest_receipt) and "deductions" the TDS debited there for it + * (m_tds_deduction) - TDS is tracked and taken where the interest is received, and the deposit + * itself is never debited. Interest still sitting in a deposit is not counted until it is paid out. + * Rows whose savings transaction has been reversed are left out: reversed money was never received. * *

- * Savings accounts are included deliberately. Section 194A does not reach them, and the caller - * keeps their interest out of the aggregate - but the branch still has to see that the customer - * holds one, and the row on screen shows it as exempt rather than leaving it out entirely. + * On a savings account, "interest" is its own interest postings. The charge does not reach them, and + * the caller keeps them out of the aggregate - but the branch still has to see them. * *

- * Reversed postings are excluded: a reversed posting was never income, and leaving it in would - * deduct tax on interest the customer never received. + * Scoped to the signed-in user's office and the offices below it, on the deductee's office. A branch + * login therefore receives only its own customers. Each customer still arrives with every one of + * their deposits, wherever held, because the threshold is tested on the bank as a whole. */ private static final String SQL = """ + WITH acc AS ( + SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.activatedon_date, sa.account_status, + sa.client_id AS holder_id, %s AS deductee_id + FROM m_savings_account sa + WHERE sa.client_id IS NOT NULL + ), + lines AS ( + SELECT t.savings_account_id AS account_id, 'C' AS kind, t.transaction_date AS d, t.amount AS amt + FROM m_savings_account_transaction t + JOIN m_savings_account s ON s.id = t.savings_account_id AND s.deposit_type_enum = 100 + WHERE t.transaction_type_enum = ? AND t.is_reversed = false AND t.transaction_date BETWEEN ? AND ? + UNION ALL + SELECT r.source_account_id, 'C', r.received_on, r.interest_amount + FROM m_tds_interest_receipt r + JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false + WHERE r.received_on BETWEEN ? AND ? + UNION ALL + SELECT dd.source_account_id, 'D', dd.deducted_on, dd.amount + FROM m_tds_deduction dd + JOIN m_savings_account_transaction t ON t.id = dd.savings_transaction_id AND t.is_reversed = false + WHERE dd.deducted_on BETWEEN ? AND ? + ) SELECT c.id AS client_id, c.account_no AS customer_id, c.display_name AS name, c.date_of_birth AS date_of_birth, - k.pan_number AS pan, - k.form15_type AS form15_type, + (SELECT k.pan_number FROM m_client_kyc k + WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + ORDER BY k.id DESC LIMIT 1) AS pan, o.name AS branch_name, - sa.id AS account_id, - sa.account_no AS account_no, - sa.activatedon_date AS opened_on, - CASE sa.deposit_type_enum + a.id AS account_id, + a.account_no AS account_no, + a.activatedon_date AS opened_on, + a.account_status AS account_status, + a.holder_id AS holder_id, + h.display_name AS holder_name, + CASE a.deposit_type_enum WHEN 100 THEN 'depositAccountType.savingsDeposit' WHEN 200 THEN 'depositAccountType.fixedDeposit' WHEN 300 THEN 'depositAccountType.recurringDeposit' ELSE 'depositAccountType.unknown' END AS deposit_type_code, - t.transaction_date AS credit_date, - t.amount AS credit_amount - FROM m_savings_account sa - JOIN m_client c ON c.id = sa.client_id - LEFT JOIN m_office o ON o.id = c.office_id - LEFT JOIN m_client_kyc k ON k.client_id = c.id - LEFT JOIN m_savings_account_transaction t - ON t.savings_account_id = sa.id - AND t.transaction_type_enum = ? - AND t.is_reversed = false - AND t.transaction_date >= ? - AND t.transaction_date <= ? - WHERE sa.client_id IS NOT NULL - ORDER BY c.display_name, sa.account_no, t.transaction_date - """; + l.kind AS line_kind, + l.d AS txn_date, + l.amt AS txn_amount + FROM acc a + JOIN m_client c ON c.id = a.deductee_id + JOIN m_office o ON o.id = c.office_id + JOIN m_client h ON h.id = a.holder_id + LEFT JOIN lines l ON l.account_id = a.id + WHERE o.hierarchy LIKE ? + AND (CAST(? AS BIGINT) IS NULL OR c.id = CAST(? AS BIGINT)) + ORDER BY c.display_name, a.account_no, l.d + """.formatted(TdsSql.deducteeOf("sa")); private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; @Override - public List retrieveForFinancialYear(final int financialYear) { + public List retrieveForFinancialYear(final int financialYear, final Long onlyClientId) { // The Indian financial year, 1 April to 31 March. final LocalDate from = LocalDate.of(financialYear, 4, 1); final LocalDate to = LocalDate.of(financialYear + 1, 3, 31); + final java.sql.Date f = java.sql.Date.valueOf(from); + final java.sql.Date t = java.sql.Date.valueOf(to); + final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%"; final Map byClient = new LinkedHashMap<>(); jdbcTemplate.query(SQL, rs -> { final long clientId = rs.getLong("client_id"); final Deductee deductee = byClient.computeIfAbsent(clientId, - id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), - readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"), - readString(rs, "form15_type"))); + id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), readString(rs, "pan"), + readDate(rs, "date_of_birth"), readString(rs, "branch_name"))); final long accountId = rs.getLong("account_id"); - final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id, - readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on"))); + final Account account = deductee.accounts.computeIfAbsent(accountId, + id -> new Account(id, readString(rs, "account_no"), readString(rs, "deposit_type_code"), readLong(rs, "holder_id"), + readString(rs, "holder_name"), readString(rs, "account_status"), readDate(rs, "opened_on"))); - // Null where the outer join found no posting inside the year: the account is still - // listed, it simply earned nothing in the window. - final java.sql.Date creditDate = rs.getDate("credit_date"); - if (creditDate != null) { - final BigDecimal amount = rs.getBigDecimal("credit_amount"); - account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo)); + // Null where the outer join found nothing inside the year: the account is still listed, it + // simply earned nothing in the window. + final java.sql.Date date = rs.getDate("txn_date"); + if (date != null) { + final BigDecimal amount = rs.getBigDecimal("txn_amount"); + final TdsInterestCreditData line = new TdsInterestCreditData(date.toLocalDate(), amount, account.accountNo); + if ("D".equals(rs.getString("line_kind"))) { + account.deductions.add(line); + } else { + account.credits.add(line); + } } - }, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to)); + }, f, TdsSql.INTEREST_POSTING, f, t, f, t, f, t, hierarchy, onlyClientId, onlyClientId); + + final Map> declarations = declarationsFor(TdsSql.fyLabel(financialYear)); final List result = new ArrayList<>(); for (final Deductee d : byClient.values()) { final List accounts = new ArrayList<>(); for (final Account a : d.accounts.values()) { - // accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses - // to decide whose PAN carries the deduction. Fineract does not model it on the - // savings account, so it is left null and the caller falls back to the account - // holder rather than inventing a joint holder who may not exist. - accounts.add( - new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits)); + accounts.add(new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, d.clientId, a.holderId, a.holderName, a.accountStatus, + a.openedOn, a.credits, a.deductions)); } result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName, - d.form15Type, accounts)); + declarations.getOrDefault(d.clientId, List.of()), accounts)); } return result; } + private Map> declarationsFor(final String fy) { + final Map> out = new LinkedHashMap<>(); + jdbcTemplate.query(""" + SELECT id, client_id, fy_label, form_type, uin, accepted_on, estimated_income + FROM m_tds_declaration + WHERE fy_label = ? AND is_revoked = false + ORDER BY accepted_on + """, rs -> { + final TdsDeclarationData d = new TdsDeclarationData(rs.getLong("id"), rs.getLong("client_id"), rs.getString("fy_label"), + rs.getString("form_type"), rs.getString("uin"), readDate(rs, "accepted_on"), rs.getBigDecimal("estimated_income"), + false, null, null); + out.computeIfAbsent(d.clientId(), k -> new ArrayList<>()).add(d); + }, fy); + return out; + } + private static String readString(final ResultSet rs, final String column) { try { return rs.getString(column); @@ -155,6 +198,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo } } + private static Long readLong(final ResultSet rs, final String column) { + try { + final long v = rs.getLong(column); + return rs.wasNull() ? null : v; + } catch (final SQLException e) { + throw new IllegalStateException("Could not read column " + column, e); + } + } + private static LocalDate readDate(final ResultSet rs, final String column) { try { final java.sql.Date value = rs.getDate(column); @@ -173,18 +225,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo private final String pan; private final LocalDate dateOfBirth; private final String branchName; - private final String form15Type; private final Map accounts = new LinkedHashMap<>(); - private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, - String branchName, String form15Type) { + private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, String branchName) { this.clientId = clientId; this.customerId = customerId; this.name = name; this.pan = pan; this.dateOfBirth = dateOfBirth; this.branchName = branchName; - this.form15Type = form15Type; } } @@ -193,13 +242,21 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo private final Long id; private final String accountNo; private final String depositTypeCode; + private final Long holderId; + private final String holderName; + private final String accountStatus; private final LocalDate openedOn; private final List credits = new ArrayList<>(); + private final List deductions = new ArrayList<>(); - private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) { + private Account(Long id, String accountNo, String depositTypeCode, Long holderId, String holderName, String accountStatus, + LocalDate openedOn) { this.id = id; this.accountNo = accountNo; this.depositTypeCode = depositTypeCode; + this.holderId = holderId; + this.holderName = holderName; + this.accountStatus = accountStatus; this.openedOn = openedOn; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsSql.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsSql.java new file mode 100644 index 000000000..1080614ff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsSql.java @@ -0,0 +1,95 @@ +/** + * 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.tds.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.regex.Pattern; + +/** + * The TDS rules both the deduction and the report must agree on, kept in one place so the passbook + * and the register cannot drift apart. + */ +public final class TdsSql { + + public static final int INTEREST_POSTING = 3; + public static final int WITHHOLD_TAX = 18; + public static final int FIXED_DEPOSIT = 200; + public static final int RECURRING_DEPOSIT = 300; + + /** A well-formed PAN with a recognised holder type - the same test the screens apply. */ + private static final Pattern PAN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$"); + private static final String PAN_HOLDER_TYPES = "ABCFGHJLPT"; + + private TdsSql() {} + + /** + * The client whose PAN carries the deduction for an account, as SQL over the alias given. + * + *

    + *
  • A minor's account follows the guardian - the interest is clubbed into the guardian's income. + * Minor means under 18 on the first day of the year, so one year has one deductee.
  • + *
  • A joint account follows the holder flagged primary - one deposit, one deductee.
  • + *
  • Anything else follows the account holder.
  • + *
+ * + * Takes one bind parameter: the financial year's first day. + */ + public static String deducteeOf(final String sa) { + return """ + (CASE + WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'MINOR' AND %1$s.guardian_client_id IS NOT NULL + AND (%1$s.minor_date_of_birth IS NULL + OR %1$s.minor_date_of_birth > CAST(? AS DATE) - INTERVAL '18 years') + THEN %1$s.guardian_client_id + WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'JOINT' + THEN COALESCE((SELECT MIN(j.client_id) FROM m_savings_account_joint_holders j + WHERE j.savings_account_id = %1$s.id AND j.is_primary = true), %1$s.client_id) + ELSE %1$s.client_id + END)""".formatted(sa); + } + + public static boolean isWellFormedPan(final String pan) { + final String p = pan == null ? "" : pan.trim().toUpperCase(); + return PAN.matcher(p).matches() && PAN_HOLDER_TYPES.indexOf(p.charAt(3)) >= 0; + } + + /** Age reached by the last day of the year: 60 at any point in it makes a senior citizen. */ + public static boolean isSeniorInFy(final LocalDate dateOfBirth, final LocalDate fyEnd) { + return dateOfBirth != null && !dateOfBirth.plusYears(60).isAfter(fyEnd); + } + + public static int fyStartYear(final LocalDate date) { + return date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + } + + /** '2026-27' for any date from 1 Apr 2026 to 31 Mar 2027. */ + public static String fyLabel(final int startYear) { + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + public static BigDecimal roundToMultiple(final BigDecimal value, final int to) { + if (to <= 1) { + return value.setScale(0, RoundingMode.HALF_UP); + } + final BigDecimal step = BigDecimal.valueOf(to); + return value.divide(step, 0, RoundingMode.HALF_UP).multiply(step); + } +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 108e7421a..020cdbd2f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 0f961c93b..8aa8e172d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -165,4 +165,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_tds_declarations_and_challans.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_tds_declarations_and_challans.xml new file mode 100644 index 000000000..4b7beb274 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_tds_declarations_and_challans.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission + WHERE code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION', 'CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION', 'CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION'); + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user'); + + + + + + + + + + INSERT INTO m_tds_declaration (client_id, fy_label, form_type, accepted_on, is_revoked, created_on) + SELECT k.client_id, k.form15_fy, UPPER(k.form15_type), + TO_DATE(SUBSTRING(k.form15_fy FROM 1 FOR 4) || '-04-01', 'YYYY-MM-DD'), false, now() + FROM m_client_kyc k + WHERE k.form15_applicable = true + AND k.form15_fy ~ '^[0-9]{4}-[0-9]{2}$' + AND UPPER(k.form15_type) IN ('15G', '15H', '121') + AND COALESCE(k.is_deleted, false) = false; + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_tds_interest_receipts_and_deductions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_tds_interest_receipts_and_deductions.xml new file mode 100644 index 000000000..4c9f0dfb4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_tds_interest_receipts_and_deductions.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index c441155be..2b48e91c1 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -795,6 +795,29 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return false; } + /** + * Deducts TDS from this savings account on FD/RD interest that has just been credited to it. + * + *

+ * Whether anything is due, and how much, is decided outside the entity - the threshold is tested on + * the customer's interest across the bank and a declaration on record switches it off. This only + * posts the withhold-tax debit and brings the running balances and summary up to date, as any other + * transaction on the account would. + * + * @param taxSplit + * the amount per tax component; the component's credit account is where the accounting + * entry sends the tax + */ + public SavingsAccountTransaction deductTds(final LocalDate date, final BigDecimal amount, final Map taxSplit) { + final SavingsAccountTransaction tds = SavingsAccountTransaction.withHoldTax(this, office(), date, Money.of(this.currency, amount), + taxSplit); + addTransaction(tds); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + recalculateDailyBalances(Money.zero(this.currency), DateUtils.isAfter(date, businessDate) ? date : businessDate, false, false); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + return tds; + } + protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) { SavingsAccountTransaction postingTransation = null; List trans = getTransactions();