From 04d1eb20f0a26d3683cf01957e5209f386c7c086 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 14 Oct 2026 13:51:01 +0530 Subject: [PATCH 1/9] feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same maker-checker path as a savings withdrawal: - the request carries gl_account_id, contra_gl_id, office_id and currency_code (new nullable columns, changeset 0600) instead of a savings account; - at submit it is validated: the paying GL must be a mapped cash vault GL and the slip's own GL must not be a cash GL; - at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry; - the cashier pays it against its token, and the denomination and voucher lines are recorded under the journal entry's id. KYC debit checks are skipped for GL requests. Co-Authored-By: Claude Sonnet 5.5 --- .../ClerkWithdrawalRequestApiResource.java | 174 ++++++++++++++++-- .../tenant/final-changelog-tenant.xml | 1 + .../0600_clerk_withdrawal_request_gl.xml | 28 +++ 3 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml 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 d579ec92e..6455ba4b6 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 @@ -48,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -110,11 +111,20 @@ public class ClerkWithdrawalRequestApiResource { final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root); final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root); final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + // A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is + // paid from instead of a savings account; it is approved and disbursed exactly like any other slip. + final boolean isGl = "GL".equalsIgnoreCase(accountType); + final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root); + final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root); + final Long officeId = fromJsonHelper.extractLongNamed("officeId", root); + final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root); if (accountNo == null || accountNo.isBlank()) { throw apiError(400, "Account number is required"); } - if (savingsAccountId == null) { + if (isGl) { + validateGlWithdrawalAccounts(glAccountId, contraGlId); + } else if (savingsAccountId == null) { throw apiError(400, "Savings account id is required for a withdrawal request"); } if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { @@ -124,7 +134,9 @@ public class ClerkWithdrawalRequestApiResource { throw apiError(400, "Token number (TOK. NO.) is required"); } validateTokenUniqueToday(tokNo, null); - kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + if (!isGl) { + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + } final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -132,8 +144,9 @@ public class ClerkWithdrawalRequestApiResource { INSERT INTO m_clerk_withdrawal_request (account_no, account_type, savings_account_id, client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date, - status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at, + gl_account_id, contra_gl_id, office_id, currency_code) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -142,7 +155,9 @@ public class ClerkWithdrawalRequestApiResource { txnDate != null ? Date.valueOf(txnDate) : null, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - denominationData, activityLog, Timestamp.from(Instant.now()) + denominationData, activityLog, Timestamp.from(Instant.now()), + isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null, + isGl ? (currencyCode != null ? currencyCode : "INR") : null ); // Globally unique voucher number from the central m_voucher register @@ -198,7 +213,8 @@ public class ClerkWithdrawalRequestApiResource { + " txn_date, status, clerk_id, clerk_name, clerk_username," + " manager_id, manager_name, manager_username, rejection_reason, txn_id," + " denomination_data, activity_log, submitted_at, actioned_at," - + " cashier_id, disbursed_by, disbursed_at, voucher_no" + + " cashier_id, disbursed_by, disbursed_at, voucher_no," + + " gl_account_id, contra_gl_id, office_id, currency_code" + " FROM m_clerk_withdrawal_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -252,7 +268,8 @@ public class ClerkWithdrawalRequestApiResource { payment_type_name, description, tok_no, txn_date, status, clerk_name, manager_name, manager_username, rejection_reason, txn_id, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray() @@ -276,7 +293,8 @@ public class ClerkWithdrawalRequestApiResource { txn_date, status, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -295,7 +313,8 @@ public class ClerkWithdrawalRequestApiResource { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, amount, payment_type_id, payment_type_name, description, tok_no, status, - clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no + clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE ORDER BY submitted_at DESC LIMIT 1 @@ -455,13 +474,16 @@ public class ClerkWithdrawalRequestApiResource { "Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it."); } + final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type")); final Long savingsAccountId = record.get("savings_account_id") != null ? ((Number) record.get("savings_account_id")).longValue() : null; // Checked again on approval, not only on submission: a request can sit in the queue for days, // and the account can cross the dormancy threshold while it waits. The savings withdrawal // below refuses a dormant account anyway, but that failure would surface to the manager as a // rejected transaction after the approval was recorded; this says why, before anything moves. - kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + if (!isGlRequest) { + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + } final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); @@ -469,7 +491,11 @@ public class ClerkWithdrawalRequestApiResource { final String description = (String) record.get("description"); String txnId = null; - if (savingsAccountId != null) { + if (isGlRequest) { + // Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal. + validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id"))); + txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description); + } else if (savingsAccountId != null) { final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no")); txnId = isCurrentAccount ? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description) @@ -477,14 +503,18 @@ public class ClerkWithdrawalRequestApiResource { } // Link voucher to the created savings txn and stamp voucher_no on it - if (txnId != null) { + if (txnId != null && !isGlRequest) { voucherService.linkSavingsTransaction( VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId)); } // The sides of the slip. A withdrawal is money leaving the member, so their account is // debited and the till that pays it out is credited. - addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId); + if (isGlRequest) { + addGlWithdrawalVoucherLines(record, amount); + } else { + addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId); + } final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null @@ -689,8 +719,8 @@ public class ClerkWithdrawalRequestApiResource { if (denomMap == null || denomMap.size() == 0) return; final Object txnIdObj = record.get("txn_id"); - final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null; - if (txnId == null) return; // no savings txn linked — cannot record denomination + final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null; + if (txnId == null) return; // no txn linked — cannot record denomination final Object saObj = record.get("savings_account_id"); final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null; @@ -793,6 +823,117 @@ public class ClerkWithdrawalRequestApiResource { return rows.isEmpty() ? null : rows.get(0); } + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + + /** + * A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one + * Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under + * the id of its own journal entry in the cashier's position and closing report. + */ + private Long parseTxnKey(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.valueOf(raw); + } catch (final NumberFormatException ignored) { + try { + final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw; + return Long.parseUnsignedLong(hex, 16); + } catch (final NumberFormatException ignored2) { + return null; + } + } + } + + /** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the + * GL it is paid from must be one of the branch's mapped cash accounts. */ + private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) { + if (glAccountId == null || contraGlId == null) { + throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request"); + } + final List cashGlIds = jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)", + Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue()); + if (!cashGlIds.contains(contraGlId)) { + throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL."); + } + if (cashGlIds.contains(glAccountId)) { + throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip."); + } + } + + /** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is + * switched off for manual postings). Returns the journal entry's transaction id. */ + private String executeGlWithdrawalJournal(final Map record, 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 date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; + + final JsonObject debit = new JsonObject(); + debit.addProperty("glAccountId", toLong(record.get("gl_account_id"))); + debit.addProperty("amount", amount); + final JsonObject credit = new JsonObject(); + credit.addProperty("glAccountId", toLong(record.get("contra_gl_id"))); + credit.addProperty("amount", amount); + final JsonArray debits = new JsonArray(); + debits.add(debit); + final JsonArray credits = new JsonArray(); + credits.add(credit); + + final Object office = record.get("office_id"); + final Object currency = record.get("currency_code"); + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L); + payload.addProperty("transactionDate", date); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR"); + if (note != null && !note.isBlank()) { + payload.addProperty("comments", note); + } + if (paymentTypeId != null) { + payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue()); + } + payload.add("credits", credits); + payload.add("debits", debits); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); + // The request raised its own voucher at submission; the journal command must not raise a second. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + return result.getTransactionId(); + } + + /** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */ + private void addGlWithdrawalVoucherLines(final Map record, final BigDecimal amount) { + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + try { + final Long glId = toLong(record.get("gl_account_id")); + final Long contraId = toLong(record.get("contra_gl_id")); + final Map gl = glAccountForVoucher(glId); + final Map contra = glAccountForVoucher(contraId); + voucherService.addLines(voucherNo, List.of( + VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount), + VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount))); + } catch (final RuntimeException e) { + LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e); + } + } + + private Map glAccountForVoucher(final Long glAccountId) { + if (glAccountId == null) { + return Map.of(); + } + return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, @@ -800,7 +941,8 @@ public class ClerkWithdrawalRequestApiResource { txn_date, status, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE id = ? """, id); if (rows.isEmpty()) { 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 4a2d7b893..53762ea3e 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 @@ -174,4 +174,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml new file mode 100644 index 000000000..ec5d99201 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + From b47a34b5ef103bd9b3ee256535c3e93928eff1b2 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:27:21 +0530 Subject: [PATCH 2/9] feat(vouchers): voucher correction for cash deposits posted to the wrong account A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back: the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it. - VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction, approve/reject correction, close request, voided transactions. - The wrong account's balance is checked when the cashier enters the correction and again at approval. - A cashier whose customer has already used the money closes the request and deposits separately. - Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for a manager or head cashier other than the maker. - Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher, since the correction status is read from m_voucher_correction alone. - Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status and leave a revoked voucher out of their totals, so its cash is not counted twice. Co-Authored-By: Claude Sonnet 5.5 --- .../cashreport/api/CashReportApiResource.java | 15 +- .../api/VoucherCorrectionApiResource.java | 623 ++++++++++++++++++ .../api/VoucherVerificationApiResource.java | 26 +- .../voucher/service/VoucherService.java | 60 ++ .../tenant/final-changelog-tenant.xml | 2 + .../tenant/parts/0601_voucher_correction.xml | 75 +++ .../0602_voucher_drop_status_columns.xml | 24 + 7 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml 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 afb0ed8bc..ca448a2c8 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 @@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -36,6 +37,7 @@ public class CashReportApiResource { private final DenominationBreakdownWriter denominationBreakdownWriter; private final CashierRoleHelper cashierRoleHelper; private final VaultPositionService vaultPositionService; + private final VoucherService voucherService; private final Gson gson = new Gson(); /** @@ -329,7 +331,18 @@ public class CashReportApiResource { BigDecimal priorCreditTotal = BigDecimal.ZERO; BigDecimal priorDebitTotal = BigDecimal.ZERO; + // Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on + // the voucher that replaced it, so it must not be counted in the day's totals a second time. + final Map> marks = voucherService.voucherMarks( + rows.stream().map(r -> (String) r.get("voucherNo")).toList()); + for (final Map row : rows) { + final Map mark = marks.get((String) row.get("voucherNo")); + row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus")); + row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo")); + row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo")); + row.put("correctionNote", mark == null ? null : mark.get("correctionNote")); + final boolean revoked = "REVOKED".equals(row.get("voucherStatus")); if (sideFilter != null && !sideFilter.equals(row.get("side"))) { continue; } @@ -350,7 +363,7 @@ public class CashReportApiResource { // was already earned, and the day it belongs to has been counted and signed off. Adding // it to this day's totals would make them disagree with the ledger for either date. final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section")); - if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) + if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java new file mode 100644 index 000000000..d153d8588 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java @@ -0,0 +1,623 @@ +/** + * 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.voucher.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +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.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +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.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the + * cashier's back. + * + *
    + *
  1. The cashier picks the day's voucher and asks to unpass it (UNPASS_PENDING). Nothing moves.
  2. + *
  3. A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply + * waiting for its correction entry.
  4. + *
  5. The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number + * (CORRECTION_PENDING).
  6. + *
  7. A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is + * reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the + * original voucher is revoked and the cashier's denominations follow the deposit.
  8. + *
+ * + * The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is + * approved, because the customer may have drawn the money in between. + */ +@Path("/v1/vouchers/corrections") +@Component +@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.") +@RequiredArgsConstructor +public class VoucherCorrectionApiResource { + + public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION"; + + private static final String UNPASS_PENDING = "UNPASS_PENDING"; + private static final String UNPASSED = "UNPASSED"; + private static final String UNPASS_REJECTED = "UNPASS_REJECTED"; + private static final String CORRECTION_PENDING = "CORRECTION_PENDING"; + private static final String COMPLETED = "COMPLETED"; + private static final String CLOSED = "CLOSED"; + private static final int SAVINGS_STATUS_ACTIVE = 300; + private static final int DEPOSIT_TYPE_SAVINGS = 100; + + private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy"); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; + private final KycRestrictionGuard kycRestrictionGuard; + private final CashierRoleHelper cashierRoleHelper; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ── Today's vouchers the cashier may unpass ────────────────────────────────── + + /** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */ + @GET + @Path("eligible") + @Produces(MediaType.APPLICATION_JSON) + public String eligible(@QueryParam("search") final String search) { + requireUnpasser(this.context.authenticatedUser()); + final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY); + final List params = new ArrayList<>(); + params.add("%/" + day + "/%"); + final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL); + if (search != null && !search.isBlank()) { + sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)"); + final String like = "%" + search.trim() + "%"; + params.add(like); + params.add(like); + params.add(like); + } + sql.append(" ORDER BY v.id DESC"); + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray())); + } + + private static final String ELIGIBLE_SQL = """ + SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt", + v.created_by_username AS "cashier", v.savings_txn_id AS "txnId", + t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo", + c.display_name AS "clientName", pt.value AS "paymentType" + FROM m_voucher v + JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id + JOIN m_savings_account sa ON sa.id = t.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + JOIN m_payment_detail pd ON pd.id = t.payment_detail_id + JOIN m_payment_type pt ON pt.id = pd.payment_type_id + WHERE v.voucher_type = 'DEPOSIT' + AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION' + AND v.voucher_no LIKE ? + AND t.is_reversed = false + AND t.transaction_type_enum = 1 + AND pt.is_cash_payment = true + AND sa.deposit_type_enum = 100 + AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc + WHERE vc.original_voucher_no = v.voucher_no + AND vc.status NOT IN ('UNPASS_REJECTED')) + """; + + // ── Step 1: cashier unpasses a voucher ─────────────────────────────────────── + + @POST + @Path("unpass") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String unpass(final String body) { + final AppUser user = this.context.authenticatedUser(); + requireUnpasser(user); + final JsonElement root = fromJsonHelper.parse(body); + final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root); + final String reason = fromJsonHelper.extractStringNamed("reason", root); + if (voucherNo == null || voucherNo.isBlank()) { + throw apiError(400, "Voucher number is required"); + } + if (reason == null || reason.isBlank()) { + throw apiError(400, "Reason is required"); + } + + final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY); + final List> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%", + voucherNo.trim()); + if (rows.isEmpty()) { + throw apiError(409, "Voucher " + voucherNo + + " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify."); + } + final Map v = rows.get(0); + + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_voucher_correction + (original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name, + amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log) + VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t + JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?), + (SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?), + ?,?,?,?,?,?) + RETURNING id + """, Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"), + v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(), + Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim())); + return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING)); + } + + // ── Lists ──────────────────────────────────────────────────────────────────── + + /** + * Corrections by status. {@code status} may be a comma-separated list. The approval page asks for + * UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) { + final AppUser user = this.context.authenticatedUser(); + final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1"); + final List params = new ArrayList<>(); + if (status != null && !status.isBlank()) { + final String[] statuses = status.split(","); + sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")"); + for (final String s : statuses) { + params.add(s.trim().toUpperCase()); + } + } + if (mine) { + sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)"); + params.add(user.getId()); + params.add(user.getId()); + } + sql.append(" ORDER BY id DESC LIMIT 200"); + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray())); + } + + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public String get(@PathParam("id") final Long id) { + this.context.authenticatedUser(); + return gson.toJson(load(id)); + } + + // ── Step 2: manager approves / rejects the unpass ──────────────────────────── + + @POST + @Path("{id}/unpass/approve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String approveUnpass(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, UNPASS_PENDING, "approved"); + requireNotMaker(user, r.get("unpass_by_id"), "unpass request"); + final int updated = jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?, + unpass_approved_at = ?, activity_log = ? + WHERE id = ? AND status = ? + """, UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), + log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING); + if (updated == 0) { + throw apiError(409, "This request was already actioned by another manager."); + } + // Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved. + return gson.toJson(Map.of("resourceId", id, "status", UNPASSED)); + } + + @POST + @Path("{id}/unpass/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String rejectUnpass(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, UNPASS_PENDING, "rejected"); + final String reason = requireReason(body); + jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED, + reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id); + return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED)); + } + + // ── Step 3: cashier enters the correct account ─────────────────────────────── + + /** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */ + @GET + @Path("account-lookup") + @Produces(MediaType.APPLICATION_JSON) + public String accountLookup(@QueryParam("accountNo") final String accountNo) { + this.context.authenticatedUser(); + final Map account = findSavingsAccount(accountNo); + if (account == null) { + throw apiError(404, "No savings account found for '" + accountNo + "'."); + } + return gson.toJson(account); + } + + @POST + @Path("{id}/correction") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String submitCorrection(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + // The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it. + if (cashierRoleHelper.getCurrentCashierId() == null) { + throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier."); + } + final Map r = load(id); + requireStatus(r, UNPASSED, "corrected"); + final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body)); + final Map correct = validateCorrectAccount(r, accountNo); + checkWrongAccountBalance(r); + + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?, + correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?, + correction_at = ?, rejection_reason = NULL, activity_log = ? + WHERE id = ? AND status = ? + """, CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"), + user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), + log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED); + return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING)); + } + + // ── Step 4: manager approves / rejects the correction ──────────────────────── + + @POST + @Path("{id}/correction/approve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String approveCorrection(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, CORRECTION_PENDING, "approved"); + requireNotMaker(user, r.get("correction_by_id"), "correction"); + + // Both sides are checked again: a lot can happen between the cashier saving and the manager approving. + validateCorrectAccount(r, (String) r.get("correct_account_no")); + checkWrongAccountBalance(r); + + final Long wrongId = toLong(r.get("wrong_savings_account_id")); + final Long correctId = toLong(r.get("correct_savings_account_id")); + final Long origTxnId = toLong(r.get("original_txn_id")); + final BigDecimal amount = new BigDecimal(r.get("amount").toString()); + final String origVoucher = (String) r.get("original_voucher_no"); + + final Integer stillLive = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false", + Integer.class, origTxnId, wrongId); + if (stillLive == null || stillLive == 0) { + throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected."); + } + + // 1. Take the deposit off the wrong account, 2. put the same cash into the right one. + final JsonObject undoPayload = new JsonObject(); + voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource( + new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build())); + + final String newTxnId = depositInto(correctId, amount, r); + final Long newTxnKey = Long.valueOf(newTxnId); + + // 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side. + final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user); + jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?", + r.get("correction_by_id"), r.get("correction_by_id"), newVoucher); + voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey); + addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"), + amount); + + // 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this + // correction (original_voucher_no / new_voucher_no) and read from there by the registers. + + // 5. The notes the cashier took in follow the deposit to its new transaction and account. + jdbcTemplate.update(""" + UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ? + WHERE txn_id = ? AND savings_account_id = ? + """, newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId); + + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?, + correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ? + WHERE id = ? AND status = ? + """, COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey, + log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING); + + return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher)); + } + + /** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */ + @POST + @Path("{id}/correction/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String rejectCorrection(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, CORRECTION_PENDING, "rejected"); + final String reason = requireReason(body); + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL, + correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ? + WHERE id = ? + """, UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id); + return gson.toJson(Map.of("resourceId", id, "status", UNPASSED)); + } + + /** + * The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has + * already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from + * their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher + * stays as it was. + */ + @POST + @Path("{id}/close") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String close(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + if (cashierRoleHelper.getCurrentCashierId() == null) { + throw apiError(403, "Only a cashier can close a voucher correction."); + } + final Map r = load(id); + requireStatus(r, UNPASSED, "closed"); + final String reason = requireReason(body); + final int updated = jdbcTemplate.update( + "UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED, + reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED); + if (updated == 0) { + throw apiError(409, "This request was already actioned."); + } + return gson.toJson(Map.of("resourceId", id, "status", CLOSED)); + } + + // ── For the passbook and transaction list of the wrong account ─────────────── + + /** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */ + @GET + @Path("voided-transactions") + @Produces(MediaType.APPLICATION_JSON) + public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) { + this.context.authenticatedUser(); + final JsonArray ids = new JsonArray(); + jdbcTemplate.queryForList( + "SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED, + savingsAccountId).forEach(ids::add); + return gson.toJson(ids); + } + + // ── helpers ────────────────────────────────────────────────────────────────── + + /** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */ + private void requireUnpasser(final AppUser user) { + final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS"); + final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim())); + if (!admin && !clerk) { + throw apiError(403, "Only an administrator or a clerk can unpass a voucher."); + } + } + + private Map validateCorrectAccount(final Map r, final String accountNo) { + if (accountNo == null || accountNo.isBlank()) { + throw apiError(400, "The correct account number is required"); + } + final Map correct = findSavingsAccount(accountNo); + if (correct == null) { + throw apiError(404, "No savings account found for '" + accountNo + "'."); + } + if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) { + throw apiError(400, "The correct account is the same account the deposit was posted to."); + } + if (!Boolean.TRUE.equals(correct.get("canReceive"))) { + throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit."); + } + final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class, + r.get("wrong_savings_account_id")).stream().findFirst().orElse(null); + if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) { + throw apiError(409, "The correct account is in a different currency."); + } + kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT); + return correct; + } + + /** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */ + private void checkWrongAccountBalance(final Map r) { + final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?", + BigDecimal.class, r.get("wrong_savings_account_id")); + final BigDecimal amount = new BigDecimal(r.get("amount").toString()); + if (balance == null || balance.compareTo(amount) < 0) { + throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹" + + (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money."); + } + } + + private Map findSavingsAccount(final String accountNo) { + if (accountNo == null || accountNo.isBlank()) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName", + sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode", + (sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive" + FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id + WHERE sa.account_no = ? + """, SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim()); + return rows.isEmpty() ? null : rows.get(0); + } + + private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map r) { + final Object txnDate = r.get("txn_date"); + final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) + : LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", date); + payload.addProperty("transactionAmount", amount); + if (r.get("payment_type_id") != null) { + payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue()); + } + payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no")); + final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()) + .build(); + final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command)); + if (result.getResourceId() == null) { + throw apiError(500, "The corrected deposit could not be posted."); + } + return result.getResourceId().toString(); + } + + /** The new voucher keeps the original's till side and names the right account on the credit side. */ + private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName, + final String accountNo, final BigDecimal amount) { + try { + final List lines = new ArrayList<>(); + for (final Map l : jdbcTemplate.queryForList( + "SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no", + origVoucher)) { + lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"), + amount)); + } + if (lines.isEmpty()) { + lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount)); + } + lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount)); + voucherService.addLines(newVoucher, lines); + } catch (final RuntimeException e) { + // The deposit is already posted; a voucher without its sides is better than undoing a correct posting. + org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class) + .error("Could not record the sides of correction voucher {}", newVoucher, e); + } + } + + private Map load(final Long id) { + final List> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id); + if (rows.isEmpty()) { + throw new NotFoundException("Voucher correction not found: " + id); + } + return rows.get(0); + } + + private void requireStatus(final Map r, final String expected, final String action) { + if (!expected.equals(r.get("status"))) { + throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status")); + } + } + + private void requireNotMaker(final AppUser user, final Object makerId, final String what) { + if (makerId != null && toLong(makerId).equals(user.getId())) { + throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager."); + } + } + + private String requireReason(final String body) { + final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}")); + if (reason == null || reason.isBlank()) { + throw apiError(400, "Reason is required"); + } + return reason.trim(); + } + + /** Checker authorization, the same rule the withdrawal and deposit request registers use. */ + private void validateCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections."); + } + } + + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + + private String log(final String existing, final String action, final AppUser user, final String note) { + final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class); + final JsonObject e = new JsonObject(); + e.addProperty("action", action); + e.addProperty("by", user.getUsername()); + e.addProperty("at", Instant.now().toString()); + if (note != null) { + e.addProperty("note", note); + } + entries.add(e); + return gson.toJson(entries); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index 75aa18bf7..e02099946 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -628,9 +628,15 @@ public class VoucherVerificationApiResource { BigDecimal total = BigDecimal.ZERO; final List> out = new java.util.ArrayList<>(); + final Map> marks = voucherService + .voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList()); for (final Map r : rows) { final BigDecimal amount = (BigDecimal) r.get("amount"); - if (amount != null) { + final Map mark = marks.get((String) r.get("voucher_no")); + final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus")); + // A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it + // is left out of the total rather than counted twice. + if (amount != null && !revoked) { total = total.add(amount); } @@ -638,6 +644,7 @@ public class VoucherVerificationApiResource { m.put("voucherNo", r.get("voucher_no")); m.put("voucherType", r.get("voucher_type")); m.put("amount", amount); + putVoucherMark(m, mark); m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); // The day the posting belongs to, and whether that differs from the day it was written. // A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's @@ -847,6 +854,8 @@ public class VoucherVerificationApiResource { int raisedCount = 0; int verifiedCount = 0; final List> out = new java.util.ArrayList<>(); + final Map> myWorkMarks = voucherService + .voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList()); for (final Map r : rows) { final BigDecimal amount = (BigDecimal) r.get("amount"); @@ -869,7 +878,10 @@ public class VoucherVerificationApiResource { final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched ? "JV" : rawSide; - if (amount != null) { + final Map mark = myWorkMarks.get(voucherNo); + final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus")); + // A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted. + if (amount != null && !revoked) { if ("CR".equals(side)) { receivedTotal = receivedTotal.add(amount); } else if ("DR".equals(side)) { @@ -897,6 +909,7 @@ public class VoucherVerificationApiResource { m.put("raisedBy", r.get("created_by_username")); m.put("branchName", r.get("branch_name")); m.put("verificationStatus", r.get("verification_status")); + putVoucherMark(m, mark); m.put("verifiedByName", r.get("verified_by_name")); m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString()); m.put("debitParty", r.get("debit_party")); @@ -968,6 +981,14 @@ public class VoucherVerificationApiResource { * returns an empty list rather than an error: the voucher is real, its posting simply was not * captured at the time, and reporting it as missing would be wrong. */ + /** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */ + private void putVoucherMark(final Map row, final Map mark) { + row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus")); + row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo")); + row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo")); + row.put("correctionNote", mark == null ? null : mark.get("correctionNote")); + } + @GET @Path("detail") public String detail(@QueryParam("voucherNo") final String voucherNo) { @@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource { out.put("createdBy", h.get("created_by_username")); out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString()); out.put("verificationStatus", h.get("verification_status")); + putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo)); out.put("lines", lines); out.put("totalDebit", totalDebit); out.put("totalCredit", totalCredit); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index be8aa7f6b..a43c8b6a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -595,6 +595,66 @@ public class VoucherService { """, voucherNo); } + /** + * Where each of these vouchers stands, for the registers that list vouchers. + * + * A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a + * new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING, + * UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in + * replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its + * money now sits on the replacement. + */ + public Map> voucherMarks(final java.util.Collection voucherNos) { + final Map> marks = new java.util.HashMap<>(); + final List nos = voucherNos == null ? List.of() + : voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList(); + if (nos.isEmpty()) { + return marks; + } + final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?")); + // Read from the correction register alone: the voucher itself carries no status, so nothing about a correction + // is ever written onto m_voucher and the voucher rows stay exactly as they were raised. + final List> rows = jdbcTemplate.queryForList( + "SELECT v.voucher_no," + + " (SELECT vc.new_voucher_no FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'" + + " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no," + + " (SELECT vc.original_voucher_no FROM m_voucher_correction vc" + + " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'" + + " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no," + + " (SELECT vc.status FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no" + + " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')" + + " ORDER BY vc.id DESC LIMIT 1) AS correction_status," + // The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed. + + " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END" + + " FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no" + + " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')" + + " ORDER BY vc.id DESC LIMIT 1) AS correction_note" + + " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")", + nos.toArray()); + for (final Map r : rows) { + final String status; + if (r.get("revoked_by_voucher_no") != null) { + status = "REVOKED"; + } else if (r.get("correction_status") != null) { + status = (String) r.get("correction_status"); + } else if (r.get("replaces_voucher_no") != null) { + status = "CORRECTED"; + } else { + status = "ACTIVE"; + } + final Map m = new java.util.LinkedHashMap<>(); + m.put("voucherStatus", status); + m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no")); + m.put("replacesVoucherNo", r.get("replaces_voucher_no")); + m.put("correctionNote", r.get("correction_note")); + marks.put((String) r.get("voucher_no"), m); + } + return marks; + } + /** * Looks up the voucher for the given request and returns its voucher_no, * or null if none exists (e.g. records created before vouchers existed). 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 53762ea3e..57c967250 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 @@ -175,4 +175,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml new file mode 100644 index 000000000..8fe23388a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml new file mode 100644 index 000000000..d1aaa1cb2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + From 6be32272c1c9da48f67aeb6b0a4566a444863875 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:27:22 +0530 Subject: [PATCH 3/9] feat(cashier): maker-checker on cash transfer Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and posted only when a different manager or head cashier approves it. - POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same checks run again at approval, since the vault can change in between. - Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a reason; the maker cannot approve their own request. - An identical request from the same maker is refused while the first is still pending. - The direct POST /cashtransfer is closed so the approval cannot be bypassed. - Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../api/CashTransferApiResource.java | 196 +++++++++++++++++- .../tenant/final-changelog-tenant.xml | 1 + .../parts/0603_cash_transfer_request.xml | 46 ++++ 3 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml 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 437999967..3c87ac908 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 @@ -57,9 +57,23 @@ public class CashTransferApiResource { private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); + /** + * Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see + * {@link #submitRequest}) and only a different person's approval posts it. The posting itself is + * {@link #postTransfer}. + */ @POST public String create(String body) { - this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER"); + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required", + "A cash transfer is raised as a request and posted when a manager approves it."); + } + + /** + * Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry. + * A dry run stops after every check has passed, which is what a request is held to when it is raised and again + * when it is approved, since the vault can change in between. + */ + private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull() @@ -102,6 +116,9 @@ public class CashTransferApiResource { } validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate); + if (dryRun) { + return "{}"; + } CashTransfer header = new CashTransfer(); header.setTransferDate(transferDate); @@ -112,15 +129,9 @@ public class CashTransferApiResource { header.setTransferAmount(total); header.setNote(note); header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - try { - AppUser user = this.context.authenticatedUser(); - if (user != null) { - header.setPerformedBy(user.getId()); - header.setPerformedByUsername(user.getUsername()); - } - } catch (Exception e) { - // resolving the current user must never block recording the transfer - } + // Recorded against the person who raised the request, not the one who approved it. + header.setPerformedBy(performedBy); + header.setPerformedByUsername(performedByUsername); // Save header + details in one committed transaction, then GL dispatch separately. // This prevents EclipseLink from flushing pending detail rows inside Fineract's @@ -159,6 +170,171 @@ public class CashTransferApiResource { return gson.toJson(result); } + // ── Maker-checker: the allocation is a request until a different person approves it ──────────── + + /** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */ + @POST + @Path("requests") + public String submitRequest(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("CREATE_CASHTRANSFER"); + postTransfer(body, user.getId(), user.getUsername(), true); + + // The same request, from the same person, is not raised twice while the first is still waiting: a second tap or + // a second click would otherwise release the same cash twice once both were approved. + final Integer identical = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class, + REQUEST_PENDING, user.getId(), body); + if (identical != null && identical > 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate", + "An identical cash transfer request from you is already waiting for approval."); + } + + final JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + final JsonArray denominations = json.getAsJsonArray("denominations"); + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_cash_transfer_request + (transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body, + status, maker_id, maker_name, maker_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING id + """, Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")), + json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"), + textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(), + user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now())); + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_PENDING); + return gson.toJson(result); + } + + /** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */ + @GET + @Path("requests") + public String listRequests(@QueryParam("status") final String status, + @QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) { + final AppUser user = this.context.authenticatedUser(); + final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1"); + final List params = new java.util.ArrayList<>(); + if (status != null && !status.isBlank()) { + final String[] statuses = status.split(","); + sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")"); + for (final String s : statuses) { + params.add(s.trim().toUpperCase()); + } + } + if (mine) { + sql.append(" AND maker_id = ?"); + params.add(user.getId()); + } + sql.append(" ORDER BY id DESC LIMIT 200"); + final List> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray()); + for (final Map row : rows) { + // The notes asked for, so the approver sees what they are releasing without opening anything. + final Object requestBody = row.remove("request_body"); + row.put("denominations", requestBody == null ? new JsonArray() + : JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations")); + } + return gson.toJson(rows); + } + + /** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */ + @POST + @Path("requests/{id}/approve") + public String approveRequest(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + requireChecker(user); + final Map r = loadRequest(id); + requirePending(r, "approved"); + final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null; + if (makerId != null && makerId.equals(user.getId())) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user", + "Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager."); + } + final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false); + final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject(); + final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null; + + final int updated = jdbcTemplate.update(""" + UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ? + WHERE id = ? AND status = ? + """, REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()), + transferId, id, REQUEST_PENDING); + if (updated == 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned", + "This request was already actioned by another manager."); + } + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_APPROVED); + if (transferId != null) { + result.addProperty("cashTransferId", transferId); + } + if (postedJson.has("glWarning")) { + result.add("glWarning", postedJson.get("glWarning")); + } + return gson.toJson(result); + } + + @POST + @Path("requests/{id}/reject") + public String rejectRequest(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + requireChecker(user); + final Map r = loadRequest(id); + requirePending(r, "rejected"); + final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject(); + final String reason = textOrNull(json, "reason"); + if (reason == null || reason.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject."); + } + jdbcTemplate.update(""" + UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ? + WHERE id = ? AND status = ? + """, REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()), + reason.trim(), id, REQUEST_PENDING); + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_REJECTED); + return gson.toJson(result); + } + + private static final String REQUEST_PENDING = "PENDING"; + private static final String REQUEST_APPROVED = "APPROVED"; + private static final String REQUEST_REJECTED = "REJECTED"; + + private Map loadRequest(final Long id) { + final List> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found."); + } + return rows.get(0); + } + + private void requirePending(final Map request, final String action) { + if (!REQUEST_PENDING.equals(request.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status", + "Only PENDING requests can be " + action + ". Current status: " + request.get("status")); + } + } + + /** Checker authorization, the same rule the withdrawal and deposit request registers use. */ + private void requireChecker(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker", + "Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer."); + } + } + + private static String textOrNull(final JsonObject json, final String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null; + } + @GET public String getAll( @QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset, 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 57c967250..959cdfcfa 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 @@ -177,4 +177,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml new file mode 100644 index 000000000..5d88f52d8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 6cfa7ef3d121b7fcd3adf57a6366eb1283e5d9ff Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 15:49:14 +0530 Subject: [PATCH 4/9] feat(approvals): live Approvals badge counts over one SSE stream One endpoint now answers how many requests wait on every Approval Center tab, so the screen no longer makes a call per tab. - GET /v1/approvals/counts: snapshot, numbers only (held 3s, fresh=true skips). - GET /v1/approvals/counts/stream: Server-Sent Events. A `counts` event is written the moment the numbers change; one shared 1s check serves every open screen and callers counted alike share one query. Keep-alive ping every 15s, stream recycled every 5 minutes. - All 26 tabs are counted by one statement of scalar sub-selects, each the same table/status/office scope/exclusions as the tab's own API. A tab the caller may not see is not asked (permissions mirrored, disbursement for managers only). - Layered: thin API resource, ApprovalCountsReadPlatformService (+Impl), ApprovalCountsStreamService, ApprovalCounterRegistry (one line per tab). - Adds jersey-media-sse (SseEventSink / Sse). Co-Authored-By: Claude Sonnet 5.5 --- fineract-provider/dependencies.gradle | 2 + .../api/ApprovalCountsApiResource.java | 67 ++++++++ .../approvals/data/ApprovalCounter.java | 14 ++ .../approvals/data/ApprovalCountsData.java | 27 ++++ .../approvals/data/ApprovalCountsPlan.java | 22 +++ .../service/ApprovalCounterRegistry.java | 122 +++++++++++++++ .../ApprovalCountsReadPlatformService.java | 24 +++ ...ApprovalCountsReadPlatformServiceImpl.java | 114 ++++++++++++++ .../service/ApprovalCountsStreamService.java | 145 ++++++++++++++++++ 9 files changed, 537 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/api/ApprovalCountsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCounter.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsPlan.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCounterRegistry.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsStreamService.java diff --git a/fineract-provider/dependencies.gradle b/fineract-provider/dependencies.gradle index ee070527e..34e0cb89f 100644 --- a/fineract-provider/dependencies.gradle +++ b/fineract-provider/dependencies.gradle @@ -81,6 +81,8 @@ dependencies { 'org.apache.avro:avro', 'jakarta.ws.rs:jakarta.ws.rs-api', 'org.glassfish.jersey.media:jersey-media-multipart', + // Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one. + 'org.glassfish.jersey.media:jersey-media-sse', 'org.springframework:spring-jms', 'org.springframework:spring-context-support', diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/api/ApprovalCountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/api/ApprovalCountsApiResource.java new file mode 100644 index 000000000..a94ed4c8b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/api/ApprovalCountsApiResource.java @@ -0,0 +1,67 @@ +package org.apache.fineract.portfolio.approvals.api; + +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.DefaultValue; +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.CacheControl; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService; +import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +/** + * How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu. + * Numbers only: opening a tab still loads that tab's own list. The counting is in + * {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}. + */ +@Path("/v1/approvals/counts") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class ApprovalCountsApiResource { + + private final PlatformSecurityContext context; + private final ApprovalCountsReadPlatformService readService; + private final ApprovalCountsStreamService streamService; + + /** + * The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held + * for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback. + */ + @GET + public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) { + final AppUser user = this.context.authenticatedUser(); + final CacheControl noStore = new CacheControl(); + noStore.setNoStore(true); + return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build(); + } + + /** + * Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change, + * so a screen neither polls nor reloads. The first event carries the numbers as they stand. + */ + @GET + @Path("stream") + @Produces(MediaType.SERVER_SENT_EVENTS) + public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) { + // Ask reverse proxies not to hold the events back. + response.setHeader("X-Accel-Buffering", "no"); + response.setHeader("Cache-Control", "no-cache, no-store"); + try { + this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse); + } catch (final RuntimeException e) { + sink.close(); + throw e; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCounter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCounter.java new file mode 100644 index 000000000..54ade4863 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCounter.java @@ -0,0 +1,14 @@ +package org.apache.fineract.portfolio.approvals.data; + +/** + * One Approval Center tab and the question that counts what waits on it. + * + * @param key + * the tab's route segment, which is also the key its badge reads on the screen + * @param permission + * the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees + * the tab + * @param sql + * a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan} + */ +public record ApprovalCounter(String key, String permission, String sql) {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsData.java new file mode 100644 index 000000000..7db1dcc44 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsData.java @@ -0,0 +1,27 @@ +package org.apache.fineract.portfolio.approvals.data; + +import com.google.gson.Gson; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal + * exactly when the numbers are, which is how a change is told from a repeat. + */ +public record ApprovalCountsData(long total, Map counts, String version) { + + private static final Gson GSON = new Gson(); + + public static ApprovalCountsData of(final Map counts) { + final long total = counts.values().stream().mapToLong(Long::longValue).sum(); + return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode())); + } + + public String toJson() { + final Map json = new LinkedHashMap<>(); + json.put("total", this.total); + json.put("counts", this.counts); + json.put("version", this.version); + return GSON.toJson(json); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsPlan.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsPlan.java new file mode 100644 index 000000000..9f3b1f947 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/data/ApprovalCountsPlan.java @@ -0,0 +1,22 @@ +package org.apache.fineract.portfolio.approvals.data; + +import java.util.List; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; + +/** + * What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those + * tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then + * be used from any thread. + * + * @param tenant + * the tenant the counts are read from + * @param visible + * the tabs this caller may see, in the order the Approval Center shows them + * @param params + * the named parameters the tabs' queries use + * @param group + * equal for callers who are counted identically, so they can share one query + */ +public record ApprovalCountsPlan(FineractPlatformTenant tenant, List visible, MapSqlParameterSource params, + String group) {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCounterRegistry.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCounterRegistry.java new file mode 100644 index 000000000..1d18c230b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCounterRegistry.java @@ -0,0 +1,122 @@ +package org.apache.fineract.portfolio.approvals.service; + +import java.util.List; +import org.apache.fineract.portfolio.approvals.data.ApprovalCounter; + +/** + * Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the + * one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new + * tab, add one line here and one badge on the screen. + * + * The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head + * office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command). + */ +public final class ApprovalCounterRegistry { + + private ApprovalCounterRegistry() {} + + /** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */ + private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT'," + + "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'"; + + /** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */ + private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT'," + + "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'"; + + /** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */ + private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'"; + + private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB'," + + "'DDSBATCH','DDSCOLLECTIONENTRY'"; + + /** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */ + private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 " + + "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) " + + "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id " + + "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid " + + "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name " + + "AND p.entity_name = aud.entity_name)) "; + + /** Where the person who raised a loan or account sits, for the office scope the tab applies. */ + private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) " + + "AND o.hierarchy LIKE :h)"; + + /** The one tab whose list is open to managers and head cashiers only. */ + public static final String DISBURSEMENT_REQUESTS = "disbursement-requests"; + + /** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */ + public static final List ALL = List.of( + // ── Fineract's own maker-checker and pending lists ──────────────────────────── + new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ", + "SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") " + + "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"), + new ApprovalCounter("client-approval", "READ_CLIENT", + "SELECT count(*) FROM m_client c WHERE c.status_enum = 100 " + + "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"), + new ApprovalCounter("loan-approval", "READ_LOAN", + "SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id " + + "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE), + new ApprovalCounter("loan-disbursal", "READ_LOAN", + "SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id " + + "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE), + new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN", + "SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"), + new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ", + "SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id " + + "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 " + + "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE + + ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 " + + "AND COALESCE(ca.terminate_true, false) = false " + + "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"), + new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_share_account WHERE status_enum = 100"), + new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ", + "SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' " + + "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING + + "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") " + + "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"), + new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ", + "SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') " + + "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') " + + "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"), + new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"), + new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"), + // ── Requests raised by clerks, cashiers and managers ────────────────────────── + new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"), + new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"), + new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"), + new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"), + new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id " + + "WHERE p.status = 100"), + new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id " + + "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id " + + "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"), + new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"), + new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"), + new ApprovalCounter("dds-approvals", null, + "SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p " + + "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true " + + "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"), + new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST", + "SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"), + new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ", + "SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"), + new ApprovalCounter("dd-issue-approvals", null, + "SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"), + new ApprovalCounter("dd-reprint-approvals", null, + "SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"), + new ApprovalCounter("dd-revoke-approvals", null, + "SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"), + new ApprovalCounter("risk-change-approvals", null, + "SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'")); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformService.java new file mode 100644 index 000000000..89c224cb2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformService.java @@ -0,0 +1,24 @@ +package org.apache.fineract.portfolio.approvals.service; + +import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData; +import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan; +import org.apache.fineract.useradministration.domain.AppUser; + +/** Reads how many requests wait on each Approval Center tab, in one statement. */ +public interface ApprovalCountsReadPlatformService { + + /** + * Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the + * tenant of the current request. + */ + ApprovalCountsPlan planFor(AppUser user); + + /** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */ + ApprovalCountsData retrieve(ApprovalCountsPlan plan); + + /** + * Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together + * cost the database one query. {@code fresh} skips the held answer. + */ + ApprovalCountsData retrieveHeld(AppUser user, boolean fresh); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformServiceImpl.java new file mode 100644 index 000000000..9a3c146d1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsReadPlatformServiceImpl.java @@ -0,0 +1,114 @@ +package org.apache.fineract.portfolio.approvals.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.approvals.data.ApprovalCounter; +import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData; +import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is + * one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked. + */ +@Service +@RequiredArgsConstructor +public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService { + + /** Screens asking together read one answer instead of each running the statement. */ + private static final long HOLD_MILLIS = 3_000L; + + private record Held(long at, ApprovalCountsData data) {} + + private final JdbcTemplate jdbcTemplate; + private final Map held = new ConcurrentHashMap<>(); + + @Override + public ApprovalCountsPlan planFor(final AppUser user) { + final List visible = new ArrayList<>(); + for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) { + if (isVisible(user, counter)) { + visible.add(counter); + } + } + final String hierarchy = user.getOffice().getHierarchy(); + final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER"); + final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%") + .addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker); + final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant(); + // Callers counted the same way share one query: only a limited checker's numbers depend on who they are. + final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|' + + (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(",")); + return new ApprovalCountsPlan(tenant, visible, params, group); + } + + @Override + public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) { + final Map counts = new LinkedHashMap<>(); + final List visible = plan.visible(); + if (!visible.isEmpty()) { + final StringBuilder sql = new StringBuilder("SELECT "); + for (int i = 0; i < visible.size(); i++) { + sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i); + } + final Map row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(), + plan.params()); + for (int i = 0; i < visible.size(); i++) { + final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L; + if (n > 0) { + counts.put(visible.get(i).key(), n); + } + } + } + return ApprovalCountsData.of(counts); + } + + @Override + public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) { + final ApprovalCountsPlan plan = planFor(user); + final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId(); + final long now = System.currentTimeMillis(); + final Held recent = this.held.get(holdKey); + if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) { + return recent.data(); + } + final ApprovalCountsData data = retrieve(plan); + this.held.put(holdKey, new Held(now, data)); + if (this.held.size() > 500) { + this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS); + } + return data; + } + + /** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */ + private boolean isVisible(final AppUser user, final ApprovalCounter counter) { + if (counter.permission() != null) { + final boolean allowed = counter.permission().startsWith("READ_") + ? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ") + : user.hasAnyPermission(counter.permission()); + if (!allowed) { + return false; + } + } + return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user); + } + + private boolean isChecker(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return true; + } + return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsStreamService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsStreamService.java new file mode 100644 index 000000000..93140b342 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/approvals/service/ApprovalCountsStreamService.java @@ -0,0 +1,145 @@ +package org.apache.fineract.portfolio.approvals.service; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.ws.rs.sse.OutboundSseEvent; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData; +import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan; +import org.springframework.stereotype.Service; + +/** + * Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its + * numbers change, so it neither polls nor reloads. + * + * One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share + * its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the + * server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean + * connection, which bounds what a browser accumulates on one. + */ +@Service +@RequiredArgsConstructor +public class ApprovalCountsStreamService { + + /** How often the open screens are looked at: the longest a change waits before it is pushed. */ + private static final long CHECK_MILLIS = 1_000L; + + private static final long PING_MILLIS = 15_000L; + + private static final long LIFETIME_MILLIS = 300_000L; + + /** A screen listening, and the version of the numbers it was last sent. */ + private static final class Listener { + + private final ApprovalCountsPlan plan; + private final SseEventSink sink; + private final Sse sse; + private final long expiresAt; + private volatile String version; + private volatile long lastSent; + + private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) { + this.plan = plan; + this.sink = sink; + this.sse = sse; + this.expiresAt = expiresAt; + } + } + + private final ApprovalCountsReadPlatformService readService; + private final Set listeners = ConcurrentHashMap.newKeySet(); + private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> { + final Thread thread = new Thread(runnable, "approval-counts-stream"); + thread.setDaemon(true); + return thread; + }); + + @PostConstruct + void startTicker() { + this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS); + } + + @PreDestroy + void stopTicker() { + this.ticker.shutdownNow(); + this.listeners.forEach(this::close); + } + + /** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */ + public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) { + final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS); + try { + publish(listener, this.readService.retrieve(plan)); + } catch (final RuntimeException e) { + sink.close(); + throw e; + } + this.listeners.add(listener); + } + + /** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */ + private void tick() { + if (this.listeners.isEmpty()) { + return; + } + final long now = System.currentTimeMillis(); + final Map computed = new HashMap<>(); + for (final Listener listener : this.listeners) { + try { + if (listener.sink.isClosed() || now >= listener.expiresAt) { + close(listener); + continue; + } + ApprovalCountsData data = computed.get(listener.plan.group()); + if (data == null) { + ThreadLocalContextUtil.setTenant(listener.plan.tenant()); + data = this.readService.retrieve(listener.plan); + computed.put(listener.plan.group(), data); + } + if (!data.version().equals(listener.version)) { + publish(listener, data); + } else if (now - listener.lastSent >= PING_MILLIS) { + listener.lastSent = now; + send(listener, listener.sse.newEventBuilder().comment("ping").build()); + } + } catch (final RuntimeException e) { + close(listener); + } finally { + ThreadLocalContextUtil.clearTenant(); + } + } + } + + private void publish(final Listener listener, final ApprovalCountsData data) { + listener.version = data.version(); + listener.lastSent = System.currentTimeMillis(); + send(listener, listener.sse.newEvent("counts", data.toJson())); + } + + private void send(final Listener listener, final OutboundSseEvent event) { + listener.sink.send(event).whenComplete((sent, failure) -> { + if (failure != null) { + close(listener); + } + }); + } + + private void close(final Listener listener) { + this.listeners.remove(listener); + try { + listener.sink.close(); + } catch (final RuntimeException ignored) { + // The screen is already gone. + } + } +} From 8ba0420398af82f15c2ad77dbc9d50e735832894 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 16 Oct 2026 12:30:44 +0530 Subject: [PATCH 5/9] feat(deposits): allow several FD/RD accounts on the same product for one client A client may now open any number of Fixed Deposit and Recurring Deposit accounts on the same product, even while an earlier one is still active. The one-active-account- per-product check is removed from submitFDApplication and submitRDApplication only; the same rule on plain Savings and Current accounts is unchanged. Co-Authored-By: Claude Opus 5.5 --- ...WritePlatformServiceJpaRepositoryImpl.java | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java index 2f1d19d5b..025d86582 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java @@ -48,7 +48,6 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.exception.ErrorHandler; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent; @@ -184,16 +183,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy, DepositAccountType.FIXED_DEPOSIT); - if (account.getClient() != null && account.productId() != null) { - long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct( - account.getClient().getId(), account.productId()); - if (existingCount > 0) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.product.for.client", - "Client already has an active Fixed Deposit account with the same product.", - "productId", account.productId()); - } - } + // A client may hold any number of Fixed Deposits on the same product, even while an earlier one is + // still active, so there is deliberately no one-active-account-per-product check here. final MathContext mc = MathContext.DECIMAL64; final boolean isPreMatureClosure = false; @@ -269,16 +260,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy, DepositAccountType.RECURRING_DEPOSIT); - if (account.getClient() != null && account.productId() != null) { - long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct( - account.getClient().getId(), account.productId()); - if (existingCount > 0) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.product.for.client", - "Client already has an active Recurring Deposit account with the same product.", - "productId", account.productId()); - } - } + // A client may hold any number of Recurring Deposits on the same product, even while an earlier one + // is still active, so there is deliberately no one-active-account-per-product check here. this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account); From 3a320b9c095d1986dfc115aabb21e915c2dae152 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 16 Oct 2026 12:30:44 +0530 Subject: [PATCH 6/9] feat(fixed-deposits): FD funding rules and no FD withdrawals from clerk/cashier requests New FixedDepositFundingValidator, used on every path money can reach an FD: - an FD is funded once, with exactly its principal: it must be active and not matured, hold no deposit yet, and the amount must equal its deposit amount; - when funded from a Savings/Current account (clerk Bank/Fund Transfer), that account must be a Savings or Current account, active, not debit-frozen (sub-status BLOCK / BLOCK_DEBIT), out of lock-in, belong to the FD's client or be held jointly with them, and have enough available balance after holds, lien, clearing holds and minimum balance (plus overdraft for current accounts). Applied in: - FD deposit command (counter / FD screen / approved cashier deposit requests); - cashier deposit request submit, so the counter sees the reason at once; - clerk Disbursement / Collection / Fund Transfer / Bank Transfer, at submit and again at approval before the request is locked; one request funds FDs of one customer, an FD appears once, and an FD can never be a debit leg. After posting, each funded FD's maturity date and amount are recomputed (new DepositAccountWritePlatformService.recalculateFixedDepositMaturity), since the direct book credit does not do it; - clerk withdrawal request submit refuses an FD: money leaves an FD only through its maturity or premature closure. Previously a direct book debit in Collection / Bank Transfer could take money out of an FD, bypassing the FD's own withdrawal refusal, and an FD could be credited any number of times for any amount. Co-Authored-By: Claude Opus 5.5 --- .../api/CashierDepositRequestApiResource.java | 7 + .../ClerkDisbursementRequestApiResource.java | 81 ++++++ .../ClerkWithdrawalRequestApiResource.java | 8 + ...WritePlatformServiceJpaRepositoryImpl.java | 17 ++ .../service/FixedDepositFundingValidator.java | 238 ++++++++++++++++++ .../DepositAccountWritePlatformService.java | 7 + 6 files changed, 358 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java 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 9ffd57359..436b435ef 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 @@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.slf4j.Logger; @@ -98,6 +99,7 @@ public class CashierDepositRequestApiResource { private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final VoucherService voucherService; + private final FixedDepositFundingValidator fixedDepositFundingValidator; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ── @@ -155,6 +157,11 @@ public class CashierDepositRequestApiResource { throw apiError(400, "Amount must be greater than zero"); } kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT); + // An FD is funded once, with exactly its principal. Told here at the counter rather than when the + // manager approves; the FD deposit command checks the same again at posting. + if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) { + fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount); + } validateDenominationTotal(denominationData, amount); validateDdLimit(ddFaceValue); 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 680c0d895..c5c065e36 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 @@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte 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.savings.service.DepositAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; @@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final FineractRequestContextHolder fineractRequestContextHolder; private final VoucherService voucherService; + private final FixedDepositFundingValidator fixedDepositFundingValidator; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ──────── @@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource { } } + // Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account. + validateFixedDepositLegs(creditEntries, requestType); + // Validate balance when both DR and CR entries present (collection mode) if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0 && totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) { @@ -491,6 +498,10 @@ public class ClerkDisbursementRequestApiResource { rejectRecurringDepositLeg(entry, entryType); } } + // Re-checked here, not only at submit: another request (or a counter deposit) may have funded the + // same FD while this one waited, and a refusal now still leaves the request PENDING. + final Set fundedFixedDeposits = validateFixedDepositLegs(creditEntries, + record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT"); // Atomic PENDING → PROCESSING lock: prevents two managers from concurrently // posting duplicate Fineract transactions for the same request. @@ -773,6 +784,12 @@ public class ClerkDisbursementRequestApiResource { } } // end else (disbursement/collection mode) + // A funded FD's maturity date and amount were projected off its principal with no money in it; + // the direct credit above does not recompute them, so do it now that the money has landed. + for (final Long fdId : fundedFixedDeposits) { + depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId); + } + } catch (Exception ex) { // Compensate: undo all posted Fineract transactions in reverse order (best-effort). // This restores account balances even when a later entry fails mid-processing. @@ -1223,6 +1240,70 @@ public class ClerkDisbursementRequestApiResource { } } + /** + * Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label. + *
    + *
  • An FD is never debited here: money leaves an FD only through its maturity or premature closure. The + * direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.
  • + *
  • An FD credit is its first and only funding, for exactly its principal, and an FD appears once.
  • + *
  • The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund + * them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is + * the bank's own money and has no customer to compare.
  • + *
+ * + * @return the ids of the FDs this request funds, for the maturity recalculation after posting + */ + private Set validateFixedDepositLegs(final JsonArray entries, final String requestType) { + final Set fdIds = new LinkedHashSet<>(); + Long firstFdId = null; + Long fdClientId = null; + for (final JsonElement el : entries) { + final JsonObject entry = el.getAsJsonObject(); + final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; + if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId") + || entry.get("savingsAccountId").isJsonNull()) { + continue; + } + final Long accountId = entry.get("savingsAccountId").getAsLong(); + if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) { + continue; + } + final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId); + if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) { + throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only" + + " through its maturity or premature closure."); + } + if (!fdIds.add(accountId)) { + throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once," + + " with its full principal in one entry."); + } + final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null); + final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount); + if (firstFdId == null) { + firstFdId = accountId; + fdClientId = clientId; + } else if (!Objects.equals(fdClientId, clientId)) { + throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer" + + " only. Raise a separate request for each customer."); + } + } + if (fdIds.isEmpty()) { + return fdIds; + } + for (final JsonElement el : entries) { + final JsonObject entry = el.getAsJsonObject(); + final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; + if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type) + || !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) { + continue; + } + final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null); + fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(), + "CURRENT".equals(type), amount); + } + return fdIds; + } + /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ private static final int SAVINGS_STATUS_ACTIVE = 300; 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 6455ba4b6..e18c3092d 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 @@ -57,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.slf4j.Logger; @@ -85,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource { private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final VoucherService voucherService; + private final FixedDepositFundingValidator fixedDepositFundingValidator; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ───────── @@ -127,6 +129,12 @@ public class ClerkWithdrawalRequestApiResource { } else if (savingsAccountId == null) { throw apiError(400, "Savings account id is required for a withdrawal request"); } + // Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip. + // Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account. + if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) { + throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from." + + " Money leaves a fixed deposit only through its maturity or premature closure."); + } if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw apiError(400, "Amount must be greater than zero"); } 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 aa9afdb81..dccc11ba5 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 @@ -392,6 +392,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate"); final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount"); + // An FD is funded once, with exactly its principal (see FixedDepositFundingValidator). + new FixedDepositFundingValidator(this.jdbcTemplate).validateFirstFunding(savingsId, transactionAmount); + final Map changes = new LinkedHashMap<>(); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); this.denominationBreakdownWriter.validatePreviousDayClosed(); @@ -422,6 +425,20 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo } + @Transactional + @Override + public void recalculateFixedDepositMaturity(final Long savingsId) { + final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId, + DepositAccountType.FIXED_DEPOSIT); + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + final boolean isPreMatureClosure = false; + account.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + this.savingAccountRepositoryWrapper.saveAndFlush(account); + } + @Transactional @Override public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java new file mode 100644 index 000000000..808067ccd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java @@ -0,0 +1,238 @@ +/** + * 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.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The rules a fixed deposit is funded under, shared by every path money can reach an FD by: the FD deposit + * command itself (cash at the counter, approved cashier deposit requests) and the clerk's Bank Transfer, + * Fund Transfer and Disbursement requests, which credit the account directly. + * + *

+ * An FD opens with no money in it and is funded exactly once, with exactly its principal. A second credit + * would silently change the principal the maturity was agreed on; anything more goes into a new FD (several + * FDs on the same product are allowed). Money never leaves an FD through these paths either - only through + * its maturity or premature closure. + * + *

+ * When the money comes from the customer's own Savings or Current account, that account must be able to pay + * it: active, not debit-frozen, out of any lock-in, belonging to the FD's client (or held jointly with them), + * and holding enough available balance once holds, liens, clearing holds and its minimum balance are set + * aside. The KYC debit restriction on that account is checked by the callers through KycRestrictionGuard. + */ +@Component +@RequiredArgsConstructor +public class FixedDepositFundingValidator { + + /** m_savings_account.deposit_type_enum (Fineract DepositAccountType). */ + public static final int DEPOSIT_TYPE_SAVINGS = 100; + public static final int DEPOSIT_TYPE_FIXED = 200; + + private static final int STATUS_ACTIVE = 300; + private static final int TXN_TYPE_DEPOSIT = 1; + /** SavingsAccountSubStatusEnum.BLOCK (no debits or credits) and BLOCK_DEBIT. */ + private static final int SUB_STATUS_BLOCK = 400; + private static final int SUB_STATUS_BLOCK_DEBIT = 600; + + private final JdbcTemplate jdbcTemplate; + + /** Whether this m_savings_account id is a fixed deposit (a CURRENT account id must not be passed here). */ + public boolean isFixedDeposit(final Long savingsAccountId) { + if (savingsAccountId == null) { + return false; + } + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?", + Integer.class, savingsAccountId, DEPOSIT_TYPE_FIXED); + return count != null && count > 0; + } + + /** + * The FD's first and only funding: the account must be an active, not yet matured fixed deposit with no + * money in it, and the amount must be exactly its principal. + * + * @return the FD's client id, for the source-account checks + */ + public Long validateFirstFunding(final Long fdAccountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT s.account_no, s.status_enum, s.deposit_type_enum, s.client_id, s.account_balance_derived, + t.deposit_amount, t.maturity_date + FROM m_savings_account s + LEFT JOIN m_deposit_account_term_and_preclosure t ON t.savings_account_id = s.id + WHERE s.id = ? + """, fdAccountId); + if (rows.isEmpty()) { + throw fail("error.msg.fd.funding.account.not.found", "Fixed deposit account " + fdAccountId + " was not found."); + } + final Map fd = rows.get(0); + final String accountNo = (String) fd.get("account_no"); + + if (intOf(fd.get("deposit_type_enum")) != DEPOSIT_TYPE_FIXED) { + throw fail("error.msg.fd.funding.not.a.fixed.deposit", "Account " + accountNo + " is not a fixed deposit."); + } + if (intOf(fd.get("status_enum")) != STATUS_ACTIVE) { + throw fail("error.msg.fd.funding.not.active", + "Fixed deposit " + accountNo + " is not active. Only an active fixed deposit can be funded."); + } + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final LocalDate maturityDate = fd.get("maturity_date") instanceof java.sql.Date d ? d.toLocalDate() : null; + if (maturityDate != null && !businessDate.isBefore(maturityDate)) { + throw fail("error.msg.fd.funding.matured", + "Fixed deposit " + accountNo + " has reached its maturity date (" + maturityDate + ") and can no longer be funded."); + } + + final Integer deposits = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_type_enum = ?" + + " AND is_reversed = false", + Integer.class, fdAccountId, TXN_TYPE_DEPOSIT); + if ((deposits != null && deposits > 0) || amountOf(fd.get("account_balance_derived")).signum() > 0) { + throw fail("error.msg.fd.funding.already.funded", "Fixed deposit " + accountNo + + " is already funded. A fixed deposit is funded only once - open a new fixed deposit for more money."); + } + + final BigDecimal principal = fd.get("deposit_amount") == null ? null : amountOf(fd.get("deposit_amount")); + if (principal == null || principal.signum() <= 0) { + throw fail("error.msg.fd.funding.principal.missing", "Fixed deposit " + accountNo + " has no deposit amount (principal) set."); + } + if (amount == null || amount.compareTo(principal) != 0) { + throw fail("error.msg.fd.funding.amount.mismatch", "Amount " + (amount == null ? "0" : amount.toPlainString()) + + " for fixed deposit " + accountNo + " must be exactly its principal " + principal.toPlainString() + " - not less, not more."); + } + return fd.get("client_id") == null ? null : ((Number) fd.get("client_id")).longValue(); + } + + /** + * The Savings or Current account an FD is being funded from. + * + * @param fdAccountId + * the FD being funded + * @param fdClientId + * its client, from {@link #validateFirstFunding} + * @param sourceAccountId + * m_savings_account id, or m_current_account id when {@code sourceIsCurrent} + */ + public void validateFundingSource(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId, + final boolean sourceIsCurrent, final BigDecimal amount) { + final List> rows = sourceIsCurrent ? jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, sub_status_enum, client_id, account_balance_derived, on_hold_funds_derived, + total_savings_amount_on_hold, min_required_balance, enforce_min_required_balance, + allow_overdraft, overdraft_limit, lockedin_until_date_derived + FROM m_current_account WHERE id = ? + """, sourceAccountId) : jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, sub_status_enum, client_id, deposit_type_enum, account_balance_derived, + on_hold_funds_derived, total_savings_amount_on_hold, total_savings_amount_on_hold_clearing, + min_required_balance, enforce_min_required_balance, lockedin_until_date_derived + FROM m_savings_account WHERE id = ? + """, sourceAccountId); + if (rows.isEmpty()) { + throw fail("error.msg.fd.funding.source.not.found", "Source account " + sourceAccountId + " was not found."); + } + final Map src = rows.get(0); + final String accountNo = (String) src.get("account_no"); + + if (!sourceIsCurrent && intOf(src.get("deposit_type_enum")) != DEPOSIT_TYPE_SAVINGS) { + throw fail("error.msg.fd.funding.source.type", + "Account " + accountNo + " cannot fund a fixed deposit - only a Savings or Current account can."); + } + if (intOf(src.get("status_enum")) != STATUS_ACTIVE) { + throw fail("error.msg.fd.funding.source.not.active", "Source account " + accountNo + " is not active."); + } + final int subStatus = intOf(src.get("sub_status_enum")); + if (subStatus == SUB_STATUS_BLOCK || subStatus == SUB_STATUS_BLOCK_DEBIT) { + throw fail("error.msg.fd.funding.source.debit.frozen", "Source account " + accountNo + " is blocked for debits (debit freeze)."); + } + final LocalDate lockedUntil = src.get("lockedin_until_date_derived") instanceof java.sql.Date d ? d.toLocalDate() : null; + if (lockedUntil != null && DateUtils.getBusinessLocalDate().isBefore(lockedUntil)) { + throw fail("error.msg.fd.funding.source.locked.in", "Source account " + accountNo + " is locked-in until " + lockedUntil + "."); + } + + final Long sourceClientId = src.get("client_id") == null ? null : ((Number) src.get("client_id")).longValue(); + if (!sameClientOrJointHolder(fdAccountId, fdClientId, sourceAccountId, sourceIsCurrent, sourceClientId)) { + throw fail("error.msg.fd.funding.source.other.client", "Source account " + accountNo + + " does not belong to the fixed deposit's customer. Fund an FD only from the customer's own account" + + " or one they hold jointly."); + } + + BigDecimal available = amountOf(src.get("account_balance_derived")).subtract(amountOf(src.get("on_hold_funds_derived"))) + .subtract(amountOf(src.get("total_savings_amount_on_hold"))); + if (!sourceIsCurrent) { + available = available.subtract(amountOf(src.get("total_savings_amount_on_hold_clearing"))); + } + if (Boolean.TRUE.equals(src.get("enforce_min_required_balance"))) { + available = available.subtract(amountOf(src.get("min_required_balance"))); + } + if (sourceIsCurrent && Boolean.TRUE.equals(src.get("allow_overdraft"))) { + available = available.add(amountOf(src.get("overdraft_limit"))); + } + if (amount == null || available.compareTo(amount) < 0) { + throw fail("error.msg.fd.funding.source.insufficient", "Source account " + accountNo + " has an available balance of " + + available.max(BigDecimal.ZERO).toPlainString() + " (after holds, lien and minimum balance), less than " + + (amount == null ? "0" : amount.toPlainString()) + "."); + } + } + + private boolean sameClientOrJointHolder(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId, + final boolean sourceIsCurrent, final Long sourceClientId) { + if (fdClientId == null) { + return false; + } + if (Objects.equals(fdClientId, sourceClientId)) { + return true; + } + // The FD's customer is a joint holder on the source account ... + final Integer onSource = sourceIsCurrent + ? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_current_account_joint_holder WHERE current_account_id = ? AND client_id = ?", + Integer.class, sourceAccountId, fdClientId) + : jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class, + sourceAccountId, fdClientId); + if (onSource != null && onSource > 0) { + return true; + } + // ... or the source account's customer is a joint holder on the FD. + if (sourceClientId == null) { + return false; + } + final Integer onFd = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class, + fdAccountId, sourceClientId); + return onFd != null && onFd > 0; + } + + private static int intOf(final Object value) { + return value instanceof Number n ? n.intValue() : -1; + } + + private static BigDecimal amountOf(final Object value) { + return value instanceof BigDecimal b ? b : value instanceof Number n ? new BigDecimal(n.toString()) : BigDecimal.ZERO; + } + + private static GeneralPlatformDomainRuleException fail(final String code, final String message) { + return new GeneralPlatformDomainRuleException(code, message); + } +} 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 afcf45061..f9639a0b7 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 @@ -39,6 +39,13 @@ public interface DepositAccountWritePlatformService { CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command); + /** + * Recomputes a fixed deposit's maturity date and amount from its real transaction history. Used after an FD + * is funded by a direct book credit (clerk Bank Transfer / Fund Transfer / Disbursement), which does not go + * through {@link #depositToFDAccount}. + */ + void recalculateFixedDepositMaturity(Long savingsId); + CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command); CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType); From 812eafc191f609c0b8aff06eb29e9dcc6f3a1819 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 20 Oct 2026 12:20:54 +0530 Subject: [PATCH 7/9] fix(accounting): clear savings-to-savings transfers through the product's Transfers In Suspense GL PROBLEM "Execute RD Auto Debit" reported success but no installment moved from the linked savings account to the RD. Every account transfer between two savings-type accounts (savings, FD, RD) books its clearing leg to the organisation-wide Financial Activity "Liability Transfer" (200). That activity was not mapped on this tenant, so the journal posting threw, the transfer rolled back, and the job only logged a WARN ("RD Auto Debit could not collect the installment ...") per installment. The tenant had never completed a single account transfer (m_account_transfer_details was empty). CHANGE The clearing leg now comes from the product's own "Transfers In Suspense" mapping (acc_product_mapping, financial_account_type = 10), which is set on the product's Accounting tab and can be changed there. GL choice for one transfer (the same GL is used for both legs, so it nets to nil): 1. Transfers In Suspense of the RECEIVING product, else 2. Transfers In Suspense of the PAYING product, else 3. Financial Activity "Liability Transfer" (previous behaviour; still throws if that is not mapped either). Savings products have no Transfers In Suspense field on screen, so for savings -> RD/FD the RD/FD product's mapping is used. Example, RD auto debit of 2,500 (Insta Money RD, suspense = 200901): Dr SAVING INDIVIDUAL (savings control) Cr LIABILITY TRANSFER SUSPENSE Dr LIABILITY TRANSFER SUSPENSE Cr RD ACCOUNT (RD control) Undo / reversal of a transfer leg reverses through the clearing GL that leg was ORIGINALLY posted to (read from acc_gl_journal_entry by savings_transaction_id: the credit row of a withdrawal, the debit row of a deposit). Transfers posted before this change (Liability Transfer) and after it (product suspense) therefore both reverse to the account they hit, even if the product mapping is changed later. FILES AccountingProcessorHelper - TRANSFER_CLEARING_ACCOUNT (ThreadLocal) - useProductTransferSuspense(toProductId, fromProductId) - useClearingAccountPostedFor(savingsTransactionId, isWithdrawal) - clearProductTransferSuspense() - getLinkedGLAccountForSavingsProduct(): returns the thread's clearing account when FinancialActivity.LIABILITY_TRANSFER is asked for and one is set; otherwise unchanged. AccountTransfersWritePlatformServiceImpl - transferFunds(), savings -> savings branch: handleWithdrawal and handleDeposit run inside useProductTransferSuspense / finally clear. - create() (Account Transfer screen/API), savings -> savings: same. The to-account is now assembled before the withdrawal (was after). - undoTransactions(): each savings leg is undone inside useClearingAccountPostedFor / finally clear. FLOWS AFFECTED (savings-type <-> savings-type, via transferFunds/create) - RD Auto Debit job, RD activation from linked savings - FD activation funded from savings - FD maturity / premature / closure / payment paid to savings - RD closure / maturity payout to savings - Transfer Interest To Savings job (FD/RD interest) - Standing instructions and Account Transfer, savings -> savings/FD/RD - Fund transfer internal strategy and SuspenseGlService (savings -> savings) - Undo of any of the above (reverseTransfersWith..., reverseAllTransactions) FLOWS NOT AFFECTED (still Financial Activity "Liability Transfer") - savings <-> loan (repayment, disbursement to savings, loan charges, guarantor recovery, Transfer Fee Charge For Loans job) - current account transfers - FD/RD closure "transfer to GL" (handleWithdrawal + manual JE against Liability Transfer in DepositAccountDomainServiceJpa) - branch-to-branch account transfer (already used Transfers In Suspense) - counter cash deposits / withdrawals IF SOMETHING BREAKS - IS IT THIS CHANGE? Likely this change if ALL of these hold: - the transaction is a transfer between two savings/FD/RD accounts (or its undo), and - the journal entry's clearing leg is on a product's Transfers In Suspense GL (e.g. 200901 LIABILITY TRANSFER SUSPENSE, or 200902 SUNDRY CREDITORS for the FD products on the local tenant) instead of the Liability Transfer financial activity GL, or - a transfer's two legs land on DIFFERENT clearing GLs, or a reversal clears a different GL than the original (suspense balance left non-zero), or - a non-transfer posting inside the same request picked up the suspense GL (would mean the ThreadLocal leaked: look for a missing clearProductTransferSuspense()). Not this change: savings <-> loan, current account, cash, or errors "No Liability Transfer account is mapped" from the FD/RD close-to-GL path. Quick checks: -- clearing legs of recent transfers SELECT j.transaction_id, j.type_enum, g.gl_code, g.name, j.amount FROM acc_gl_journal_entry j JOIN acc_gl_account g ON g.id = j.account_id WHERE j.savings_transaction_id IN ( SELECT from_savings_transaction_id FROM m_account_transfer_transaction UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction) ORDER BY j.id DESC LIMIT 40; -- a clearing GL should net to zero over completed transfers SELECT SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END) FROM acc_gl_journal_entry WHERE account_id = ; To go back to the old behaviour: revert this commit (no schema or data change is involved; transfers already posted to a product suspense GL still reverse correctly only while this commit is in place). VERIFIED - :fineract-provider:compileJava passes. - Code paths traced: all transferFunds callers, create(), undoTransactions; single-leg undo of a transfer is blocked by undoTransaction (allowAccountTransferModification=false), so reversals go through undoTransactions. NOT YET VERIFIED - No transfer has been executed end-to-end after this change (RD auto debit, FD activation from savings, undo). spotlessJavaCheck not run meaningfully: it already fails repo-wide on CRLF line endings. Co-Authored-By: Claude Opus 5.5 --- .../service/AccountingProcessorHelper.java | 67 +++++++++++++++++++ ...ountTransfersWritePlatformServiceImpl.java | 60 ++++++++++++----- 2 files changed, 112 insertions(+), 15 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 15ee85c43..a591b75d7 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 @@ -1570,9 +1570,76 @@ public class AccountingProcessorHelper { return mapping == null ? null : mapping.getGlAccount(); } + /** + * The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's + * own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer. + */ + private static final ThreadLocal TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>(); + + /** + * Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a + * deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather + * than the organisation-wide Liability Transfer financial activity. + *

+ * The receiving product's mapping is used first, then the paying product's: a savings product screen does not + * offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account, + * so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability + * Transfer as before. + *

+ * Must be paired with {@link #clearProductTransferSuspense()} in a finally block. + * + * @return true when a product mapping was found and is now in use + */ + public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) { + final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue(); + GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null); + if (account == null) { + account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null); + } + if (account == null) { + TRANSFER_CLEARING_ACCOUNT.remove(); + return false; + } + TRANSFER_CLEARING_ACCOUNT.set(account); + return true; + } + + public void clearProductTransferSuspense() { + TRANSFER_CLEARING_ACCOUNT.remove(); + } + + /** + * For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted + * to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the + * accounts out again rather than copying the original rows, so without this a transfer posted to a product's + * suspense account would be reversed against Liability Transfer and leave both accounts open. + *

+ * The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside + * it). Nothing is set where the leg has no journal entries. Must be paired with + * {@link #clearProductTransferSuspense()} in a finally block. + */ + public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) { + TRANSFER_CLEARING_ACCOUNT.remove(); + if (savingsTransactionId == null) { + return; + } + final List found = jdbcTemplate.queryForList(""" + SELECT account_id FROM acc_gl_journal_entry + WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false + ORDER BY amount DESC, id + LIMIT 1 + """, Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue()); + if (!found.isEmpty()) { + glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set); + } + } + private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, final Long paymentTypeId) { GLAccount glAccount; + if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) { + return TRANSFER_CLEARING_ACCOUNT.get(); + } if (isOrganizationAccount(accountMappingTypeId)) { FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); 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 0ddae7b62..e3c1caa99 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 @@ -144,14 +144,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer, isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance); - final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, - transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill); final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName); final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill); - final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, - transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); + // As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped. + final SavingsAccountTransaction withdrawal; + final SavingsAccountTransaction deposit; + this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId()); + try { + withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount, + paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill); + deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount, + paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); + } finally { + this.accountingProcessorHelper.clearProductTransferSuspense(); + } applyToRecurringDepositSchedule(toSavingsAccount, deposit); if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) { @@ -277,14 +285,27 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer if (accountTransfer.getToLoanTransaction() != null) { this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction()); } + // Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or + // Liability Transfer), so the reversal clears that same account. if (accountTransfer.getFromTransaction() != null) { - this.savingsAccountWritePlatformService.undoTransaction( - accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(), - true); + this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true); + try { + this.savingsAccountWritePlatformService.undoTransaction( + accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), + accountTransfer.getFromTransaction().getId(), true); + } finally { + this.accountingProcessorHelper.clearProductTransferSuspense(); + } } if (accountTransfer.getToSavingsTransaction() != null) { - this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(), - accountTransfer.getToSavingsTransaction().getId(), true); + this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false); + try { + this.savingsAccountWritePlatformService.undoTransaction( + accountTransfer.accountTransferDetails().toSavingsAccount().getId(), + accountTransfer.getToSavingsTransaction().getId(), true); + } finally { + this.accountingProcessorHelper.clearProductTransferSuspense(); + } } accountTransfer.reverse(); this.accountTransferRepository.save(accountTransfer); @@ -407,13 +428,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer transactionDate = transactionDate.plusDays(1); } - final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, - accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(), - accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill); + // Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving + // product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping. + final SavingsAccountTransaction withdrawal; + final SavingsAccountTransaction deposit; + this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId()); + try { + withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(), + transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), + transactionBooleanValues, backdatedTxnsAllowedTill); - final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, - accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(), - accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); + deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate, + accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer, + isRegularTransaction, backdatedTxnsAllowedTill); + } finally { + this.accountingProcessorHelper.clearProductTransferSuspense(); + } applyToRecurringDepositSchedule(toSavingsAccount, deposit); accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount, From 43c60223af5a2efece1f0eb4a874d0ecee7a9995 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 20 Oct 2026 16:44:47 +0530 Subject: [PATCH 8/9] feat(fixed-deposits): account-engine FD quote at opening, FD Special schedule and locked Special terms PROBLEM - The account-opening screen quoted FD maturity with a stand-alone calculator (calculate-fd-interest) that counts posting periods from the deposit date. The account engine closes them at calendar period ends (from the financial- year start month), so the quote differed from the account once created, e.g. FD DOUBLE 546,348,939 @ 6.75% for 5 years from 2026-10-21, quarterly: quoted 763,660,633.77, account 763,697,613.00. - FD Special (monthly interest payout) was quoted with compounding, and its product could be saved with Interest Calculation Type "Compounding". CHANGE 1. POST /fixeddepositaccounts/account-preview (FixedDepositAccountPreviewService) Builds the would-be account from the request exactly as submitFDApplication does (DepositAccountAssembler.assembleFrom, updateMaturityDateAndAmount- BeforeAccountActivation, applyCbsFields) inside a transaction that is always rolled back - nothing is saved. Returns the applied rate, maturity date and maturity amount Submit would store, and the interest periods from FixedDepositAccount#projectInterestSchedule (for FD Special: the monthly payout schedule, same engine as the account's Monthly Interest Payout Schedule). Every FD type. Submit's linked-account checks (linkAccountId for FD Special, linkedAccountId for Deposit Account Link) get a stand-in id (-1) when not chosen yet: both are only recorded on the account, never looked up, and the account is never saved. Submit still requires them. 2. FdSpecialProductTermsValidator: an FD Special product must have interestCalculateSelector = Simple, interestCompoundingPeriodType = Monthly and interestPostingPeriodType = Monthly. Checked server-side on create, on update (also the maker-checker approval path) and on submit-for- approval, so Postman / any client gets a 400 validation error per field. Wired through SavingsConfiguration. FILES api/FixedDepositAccountsApiResource.java (account-preview endpoint) service/FixedDepositAccountPreviewService.java (new) data/FdSpecialProductTermsValidator.java (new) service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java starter/SavingsConfiguration.java NOT CHANGED How the account calculates interest; calculate-fd-interest still exists but the opening screen no longer uses it. DATA (not in this commit) Existing FD Special products saved as "Compounding" are refused on their next edit until set to Simple (the local tenant's FD SPECIAL was updated). VERIFIED: compileJava. Numbers above reproduced independently. Not yet run end-to-end through the API. Co-Authored-By: Claude Opus 5.5 --- .../api/FixedDepositAccountsApiResource.java | 33 ++++ .../data/FdSpecialProductTermsValidator.java | 119 ++++++++++++++ .../FixedDepositAccountPreviewService.java | 148 ++++++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 7 + .../savings/starter/SavingsConfiguration.java | 5 +- 5 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/FdSpecialProductTermsValidator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountPreviewService.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java index 830f6f5a2..1bebaca36 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java @@ -109,6 +109,7 @@ public class FixedDepositAccountsApiResource { private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService; private final SqlValidator sqlValidator; private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final org.apache.fineract.portfolio.savings.service.FixedDepositAccountPreviewService fixedDepositAccountPreviewService; @GET @Path("template") @@ -315,6 +316,38 @@ public class FixedDepositAccountsApiResource { return toApiJsonSerializer.serializeResult(result); } + /** + * Fixed deposit quote while the account is being opened, for every FD type: takes the same body as Submit + * (clientId, productId, submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, isSeniorCitizen, + * locale, dateFormat ...) and returns the rate, maturity date and maturity amount Submit would store, plus the + * interest periods (FD Special: the monthly payout schedule). Nothing is saved (see FixedDepositAccountPreviewService). + */ + @POST + @Path("account-preview") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + public String accountPreview(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson); + // Submit insists on a linked savings account - "linkAccountId" (FD Special's monthly payout account) and + // "linkedAccountId" (when the product's Deposit Account Link is on) - which the screen asks for after the quote. + // The quote does not depend on which account it is, only that the interest goes out monthly, so a stand-in id is + // used for whichever is not chosen yet. Both are only recorded on the account, never looked up, and the would-be + // account is never saved, so the stand-in goes nowhere. + if (parsed.isJsonObject()) { + for (final String linkParam : new String[] { DepositsApiConstants.linkedAccountParamName, + org.apache.fineract.portfolio.savings.SavingsApiConstants.linkedAccountIdParamName }) { + if (!parsed.getAsJsonObject().has(linkParam) || parsed.getAsJsonObject().get(linkParam).isJsonNull()) { + parsed.getAsJsonObject().addProperty(linkParam, -1L); + } + } + } + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .from(parsed.toString(), parsed, this.fromJsonHelper, DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME, null, + null, null, null, null, null, null, null, null, null, null, null, null); + return this.toApiJsonSerializer.serializeResult(this.fixedDepositAccountPreviewService.preview(command)); + } + private BigDecimal getActivationCharge(Long accountId) { BigDecimal activationCharge = BigDecimal.ZERO; Collection savingCharges = this.savingsAccountChargeReadPlatformService diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/FdSpecialProductTermsValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/FdSpecialProductTermsValidator.java new file mode 100644 index 000000000..947285920 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/FdSpecialProductTermsValidator.java @@ -0,0 +1,119 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.data; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * FD Special is a non-cumulative product: its interest is paid out to the linked savings account every month, so the + * principal never grows and nothing compounds on it - in effect simple interest on the principal. Its product terms are + * therefore fixed, and the product screen locks them: + *

    + *
  • Interest Calculation Type ({@code interestCalculateSelector}) = Simple
  • + *
  • Interest Compounding Period = Monthly
  • + *
  • Interest Posting Period = Monthly
  • + *
+ * Checked here, on the server, against the product as it will be saved, so a request from Postman or any other client + * that sends different values is refused with a validation error rather than saved. Used on create, on update (which + * the maker-checker approval also goes through) and when a maker submits an edit for approval. + */ +@Component +@RequiredArgsConstructor +public class FdSpecialProductTermsValidator { + + private static final String RESOURCE = "fixeddepositproduct"; + private static final String SIMPLE = "Simple"; + + private final JdbcTemplate jdbcTemplate; + + /** The product as assembled or updated, just before it is saved. */ + public void validate(final FixedDepositProduct product) { + validate(product.getFdType(), product.getInterestCalculateSelector(), valueOf(product.interestCompoundingPeriodType()), + valueOf(product.interestPostingPeriodType())); + } + + /** + * A maker's edit, before it waits for approval: each field as the edit leaves it - the submitted value where the + * request sends one, otherwise the product's current value. + */ + public void validateEdit(final FixedDepositProduct product, final JsonCommand command) { + final String fdType = command.parameterExists("fdType") ? command.stringValueOfParameterNamed("fdType") : product.getFdType(); + final Integer selector = command.parameterExists("interestCalculateSelector") + ? command.integerValueOfParameterNamed("interestCalculateSelector") + : product.getInterestCalculateSelector(); + final Integer compounding = command.parameterExists("interestCompoundingPeriodType") + ? command.integerValueOfParameterNamed("interestCompoundingPeriodType") + : valueOf(product.interestCompoundingPeriodType()); + final Integer posting = command.parameterExists("interestPostingPeriodType") + ? command.integerValueOfParameterNamed("interestPostingPeriodType") + : valueOf(product.interestPostingPeriodType()); + validate(fdType, selector, compounding, posting); + } + + private void validate(final String fdType, final Integer interestCalculateSelector, final Integer compoundingPeriodType, + final Integer postingPeriodType) { + if (!"SPECIAL".equalsIgnoreCase(fdType)) { + return; + } + final List errors = new ArrayList<>(); + + final String calculationType = interestCalculateSelector == null ? null + : this.jdbcTemplate.queryForList("SELECT code_value FROM m_code_value WHERE id = ?", String.class, interestCalculateSelector) + .stream().findFirst().orElse(null); + if (calculationType == null || !SIMPLE.equalsIgnoreCase(calculationType.trim())) { + errors.add(ApiParameterError.parameterError("validation.msg." + RESOURCE + ".special.interestCalculateSelector.must.be.simple", + "FD Special pays its interest out every month, so nothing compounds: Interest Calculation Type must be Simple" + + (calculationType == null ? "." : ", not " + calculationType + "."), + "interestCalculateSelector", interestCalculateSelector)); + } + if (compoundingPeriodType == null || compoundingPeriodType != SavingsCompoundingInterestPeriodType.MONTHLY.getValue()) { + errors.add(ApiParameterError.parameterError( + "validation.msg." + RESOURCE + ".special.interestCompoundingPeriodType.must.be.monthly", + "FD Special pays its interest out every month: Interest Compounding Period must be Monthly.", + "interestCompoundingPeriodType", compoundingPeriodType)); + } + if (postingPeriodType == null || postingPeriodType != SavingsPostingInterestPeriodType.MONTHLY.getValue()) { + errors.add(ApiParameterError.parameterError( + "validation.msg." + RESOURCE + ".special.interestPostingPeriodType.must.be.monthly", + "FD Special pays its interest out every month: Interest Posting Period must be Monthly.", "interestPostingPeriodType", + postingPeriodType)); + } + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } + + private static Integer valueOf(final SavingsCompoundingInterestPeriodType type) { + return type == null ? null : type.getValue(); + } + + private static Integer valueOf(final SavingsPostingInterestPeriodType type) { + return type == null ? null : type.getValue(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountPreviewService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountPreviewService.java new file mode 100644 index 000000000..df7de9842 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountPreviewService.java @@ -0,0 +1,148 @@ +/** + * 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.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +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.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.domain.DepositAccountAssembler; +import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Fixed deposit quote for the account-opening screen - every FD type - worked out by the account engine itself rather + * than by the screen or a separate calculator. + *

+ * The would-be account is built exactly as Submit builds it ({@code submitFDApplication}): the same assembler (product + * defaults, the product's interest rate chart, the CBS base-rate card and senior-citizen rate), the same maturity + * calculation, the same CBS fields. Nothing is saved: it all runs in a transaction that is always rolled back. So the + * rate, maturity date and maturity amount quoted are exactly what Submit would store - including the product's posting + * periods (e.g. Quarterly posting closes each period at the calendar quarter end, from the financial-year start month), + * which a stand-alone formula that counts quarters from the deposit date gets wrong. + *

+ * The interest periods come from {@link FixedDepositAccount#projectInterestSchedule}, the method the account's Monthly + * Interest Payout Schedule uses once it is open. For FD Special they are the monthly payouts; for a cumulative FD they + * are the periods interest is credited to the deposit and compounds. + *

+ * The schedule assumes the deposit starts on the submitted date; once opened, the account's own schedule runs from its + * actual activation date. + */ +@Service +@RequiredArgsConstructor +public class FixedDepositAccountPreviewService { + + private final PlatformSecurityContext context; + private final DepositAccountAssembler depositAccountAssembler; + private final SavingsAccountAssembler savingsAccountAssembler; + private final ConfigurationDomainService configurationDomainService; + private final PlatformTransactionManager transactionManager; + + public Map preview(final JsonCommand command) { + final TransactionTemplate template = new TransactionTemplate(this.transactionManager); + return template.execute(status -> { + // Never keep anything the assembler touched: this is a quote, not an application. + status.setRollbackOnly(); + return buildPreview(command); + }); + } + + private Map buildPreview(final JsonCommand command) { + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + + // Same steps, in the same order, as DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl#submitFDApplication. + final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, + this.context.authenticatedUser(), DepositAccountType.FIXED_DEPOSIT); + final boolean special = "SPECIAL".equalsIgnoreCase(account.fdType()); + account.updateMaturityDateAndAmountBeforeAccountActivation(MathContext.DECIMAL64, false, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account); + + // Same MathContext and clipping as DepositAccountWritePlatformServiceJpaRepositoryImpl#retrieveProjectedInterestSchedule. + final RoundingMode rounding = MoneyHelper.getRoundingMode(); + final List postingPeriods = account.projectInterestSchedule(new MathContext(15, rounding), + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + final LocalDate maturityDate = account.maturityDate(); + final LocalDate lastCalculableDate = maturityDate != null ? maturityDate.minusDays(1) : null; + + final List> periods = new ArrayList<>(); + BigDecimal totalInterest = BigDecimal.ZERO; + int number = 1; + for (final PostingPeriod period : postingPeriods) { + final LocalDate fromDate = period.getPeriodInterval().startDate(); + LocalDate toDate = period.getPeriodInterval().endDate(); + if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) { + toDate = lastCalculableDate; + } + final BigDecimal interest = period.getInterestEarned().getAmount(); + totalInterest = totalInterest.add(interest); + final Map row = new LinkedHashMap<>(); + row.put("period", number++); + row.put("fromDate", fromDate.toString()); + row.put("toDate", toDate.toString()); + row.put("days", ChronoUnit.DAYS.between(fromDate, toDate) + 1); + row.put("interestAmount", interest); + periods.add(row); + } + + final BigDecimal depositAmount = account.getDepositAmount(); + final BigDecimal rate = account.getNominalAnnualInterestRate(); + final Map result = new LinkedHashMap<>(); + result.put("fdType", account.fdType()); + result.put("depositAmount", depositAmount); + result.put("annualInterestRate", rate); + result.put("seniorCitizen", account.isSeniorCitizen()); + result.put("interestPostingPeriodType", account.getInterestPostingPeriodType()); + result.put("interestCalculationDaysInYearType", account.getInterestCalculationDaysInYearType()); + result.put("startDate", periods.isEmpty() ? null : periods.get(0).get("fromDate")); + result.put("maturityDate", maturityDate == null ? null : maturityDate.toString()); + result.put("numberOfPayouts", special ? periods.size() : 0); + // FD Special: one full month at the applied rate; a period pays for the days it actually covers (see periods). + result.put("fullMonthPayout", !special || rate == null ? null + : depositAmount.multiply(rate, MoneyHelper.getMathContext()).divide(BigDecimal.valueOf(1200), MoneyHelper.getMathContext()) + .setScale(2, rounding)); + // The account's own maturity amount, as Submit stores it: for FD Special the principal alone (the interest is paid + // out month by month), otherwise principal plus the compounded interest. + final BigDecimal maturityAmount = account.maturityAmount(); + final BigDecimal interest = special ? totalInterest + : (maturityAmount == null ? null : maturityAmount.subtract(depositAmount)); + result.put("totalInterest", interest); + result.put("maturityAmount", maturityAmount); + result.put("totalValueAtMaturity", interest == null ? null : depositAmount.add(interest)); + result.put("periods", periods); + return result; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java index 053998bf9..c73789f6d 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 @@ -89,6 +89,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository; private final FromJsonHelper fromApiJsonHelper; private final CommandSourceRepository commandSourceRepository; + private final org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator; @Transactional @Override @@ -98,6 +99,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements this.fromApiJsonDataValidator.validateForFixedDepositCreate(command.json()); final FixedDepositProduct product = this.depositProductAssembler.assembleFixedDepositProduct(command); + // FD Special: Simple interest, monthly compounding and posting - refused here whatever client sent it. + this.fdSpecialProductTermsValidator.validate(product); this.fixedDepositProductRepository.saveAndFlush(product); @@ -136,6 +139,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements product.setHelpers(this.chartAssembler); final Map changes = product.update(command); + // The product as this update leaves it (also the path an approved maker-checker edit takes). + this.fdSpecialProductTermsValidator.validate(product); if (changes.containsKey(chargesParamName)) { final Set savingsProductCharges = this.depositProductAssembler.assembleListOfSavingsProductCharges(command, @@ -197,6 +202,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements } this.fromApiJsonDataValidator.validateForFixedDepositUpdate(command.json()); + // Refused before it waits for a checker, not only when the checker approves it. + this.fdSpecialProductTermsValidator.validateEdit(product, command); final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_FD, productId, maker); 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 5b012228b..b1f5f99a4 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 @@ -366,11 +366,12 @@ public class SavingsConfiguration { ProductPendingEditChargeRepository pendingEditChargeRepository, ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, FromJsonHelper fromApiJsonHelper, - CommandSourceRepository commandSourceRepository) { + CommandSourceRepository commandSourceRepository, + org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator) { return new FixedDepositProductWritePlatformServiceJpaRepositoryImpl(context, fixedDepositProductRepository, fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler, productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository, - pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository); + pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, fdSpecialProductTermsValidator); } @Bean From 7d9c5563a4442743e6a1ebef15f518a550c58280 Mon Sep 17 00:00:00 2001 From: keshav Date: Tue, 20 Oct 2026 17:11:40 +0530 Subject: [PATCH 9/9] KYC management: notice queue, stage-wise audit, RD base chart --- .../portfolio/charge/domain/Charge.java | 1 + .../infrastructure/jobs/service/JobName.java | 2 + .../data/SavingsAccountSummaryData.java | 11 +- .../aml/domain/AmlRecurringReview.java | 14 +- .../portfolio/client/domain/AadhaarHash.java | 61 ++ .../portfolio/client/domain/ClientKyc.java | 27 + .../domain/ClientKycContactLogRepository.java | 7 + .../client/domain/ClientKycRepository.java | 12 + .../client/domain/ClientRiskRule.java | 12 + .../search/SearchingClientRepository.java | 3 +- .../search/SearchingClientRepositoryImpl.java | 36 +- .../portfolio/note/domain/NoteType.java | 1 + .../CurrentAccountReadPlatformService.java | 8 + .../api/InterBranchTransferApiResource.java | 197 +++++ .../service/InterBranchCashService.java | 162 ++++ .../journalentry/service/LedgerTxnPrefix.java | 4 + .../service/IncomeProfitTransferService.java | 5 +- .../core/config/SecurityConfig.java | 11 + ...sReadPlatformServiceJpaRepositoryImpl.java | 24 +- .../api/AlmMaturityBreakdownApiResource.java | 672 +++++++++++++++ .../aml/api/AmlCcTrackingApiResource.java | 153 ++++ .../aml/job/AmlCtrReportBatchTasklet.java | 10 +- .../api/CashierDepositRequestApiResource.java | 92 ++- .../ShareAdvanceCollectionApiResource.java | 24 +- .../ChequeBookIssuanceChargeService.java | 19 +- .../ClerkDisbursementRequestApiResource.java | 235 +++++- .../api/ClerkClosureRequestApiResource.java | 25 +- ...lerkShareWithdrawalRequestApiResource.java | 26 +- .../ClerkWithdrawalRequestApiResource.java | 26 +- .../api/ClientIdentityLookupApiResource.java | 199 +++++ .../api/ClientKycContactLogApiResource.java | 25 +- .../api/ClientKycExceptionsApiResource.java | 20 + .../api/KycContactAuditApiResource.java | 434 ++++++++++ .../client/api/KycNoticeQueueApiResource.java | 157 ++++ .../client/data/ClientKycApiConstants.java | 6 +- .../client/data/KycRiskRuleData.java | 9 +- .../domain/ClientRepositoryWrapper.java | 23 + .../ProcessKycNoticeQueueConfig.java | 52 ++ .../ProcessKycNoticeQueueTasklet.java | 41 + .../SendKycRemindersConfig.java | 52 ++ .../SendKycRemindersTasklet.java | 44 + ...FamilyMembersWritePlatformServiceImpl.java | 7 +- .../client/service/ClientIdentityService.java | 207 +++++ .../service/ClientKycReadPlatformService.java | 10 + .../ClientKycReadPlatformServiceImpl.java | 9 + ...WritePlatformServiceJpaRepositoryImpl.java | 23 +- .../ClientReadPlatformServiceImpl.java | 11 +- ...WritePlatformServiceJpaRepositoryImpl.java | 4 +- .../client/service/KycContactLogService.java | 31 + .../service/KycContactLogServiceImpl.java | 88 +- .../client/service/KycNoticeComposer.java | 194 +++++ .../client/service/KycNoticeQueueService.java | 68 ++ .../service/KycNoticeQueueServiceImpl.java | 249 ++++++ .../client/service/KycReminderService.java | 39 + .../service/KycReminderServiceImpl.java | 184 +++++ .../KycRiskRulesReadPlatformServiceImpl.java | 3 +- .../service/search/ClientSearchService.java | 4 +- .../search/domain/ClientTextSearch.java | 7 + .../fineract/portfolio/note/domain/Note.java | 16 + .../portfolio/note/domain/NoteRepository.java | 3 + .../service/NoteReadPlatformServiceImpl.java | 4 + ...WritePlatformServiceJpaRepositoryImpl.java | 18 +- .../note/starter/NoteAutoConfiguration.java | 5 +- .../ProfitAppropriationConfigApiResource.java | 261 ++++++ .../ProfitAppropriationPostingService.java | 268 ++++++ .../api/RbiReserveReturnApiResource.java | 769 +++++++++++++++++- .../api/CurrentAccountChargesApiResource.java | 36 +- .../api/CurrentAccountsApiResource.java | 61 ++ .../api/RdInterestRateSaveRequest.java | 1 + .../RecurringDepositDashboardApiResource.java | 6 +- .../savings/data/RdInterestRateSlabData.java | 1 + .../domain/RdBaseInterestRateSlabHistory.java | 8 +- ...AddCurrentAccountChargeCommandHandler.java | 8 +- ...eteCurrentAccountChargeCommandHandler.java | 8 +- ...ateCurrentAccountChargeCommandHandler.java | 6 +- ...PayCurrentAccountChargeCommandHandler.java | 6 +- ...ateCurrentAccountChargeCommandHandler.java | 8 +- ...iveCurrentAccountChargeCommandHandler.java | 6 +- .../security/AccountBranchAccessFilter.java | 106 +++ .../security/AccountBranchService.java | 159 ++++ .../security/AccountNotVisibleException.java | 33 + ...rentAccountChargeWritePlatformService.java | 367 +++++++++ .../service/CurrentAccountNoteRecorder.java | 57 ++ ...CurrentAccountReadPlatformServiceImpl.java | 83 +- ...ntTransactionWritePlatformServiceImpl.java | 3 + ...urrentAccountWritePlatformServiceImpl.java | 15 +- ...DepositAccountReadPlatformServiceImpl.java | 19 +- ...WritePlatformServiceJpaRepositoryImpl.java | 6 + ...seInterestRateReadPlatformServiceImpl.java | 18 +- ...SavingsAccountReadPlatformServiceImpl.java | 5 +- ...WritePlatformServiceJpaRepositoryImpl.java | 5 + ...WritePlatformServiceJpaRepositoryImpl.java | 12 +- .../savings/starter/SavingsConfiguration.java | 17 +- ...DIssueRequestWritePlatformServiceImpl.java | 43 + .../tenant/final-changelog-tenant.xml | 11 + .../parts/0599_current_account_notes.xml | 45 + ...600_create_profit_appropriation_config.xml | 114 +++ ...01_create_profit_appropriation_posting.xml | 84 ++ .../0602_add_aadhaar_hash_to_client_kyc.xml | 65 ++ ..._account_branch_and_client_branch_link.xml | 96 +++ .../parts/0604_add_kyc_reminders_job.xml | 57 ++ .../parts/0605_create_kyc_notice_queue.xml | 118 +++ .../parts/0606_add_ctr_monthly_report_job.xml | 61 ++ ...6_rd_base_chart_senior_rate_and_months.xml | 98 +++ .../parts/0607_cc_relevant_risk_rules.xml | 90 ++ ...608_apply_rd_base_chart_to_rd_products.xml | 74 ++ .../savings/data/RdInterestRateSlabData.java | 1 + .../savings/data/SavingsAccountConstant.java | 8 +- .../savings/domain/SavingsAccount.java | 30 +- 109 files changed, 7278 insertions(+), 201 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/AadhaarHash.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/api/InterBranchTransferApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/service/InterBranchCashService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/almreturn/api/AlmMaturityBreakdownApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCcTrackingApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientIdentityLookupApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycContactAuditApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycNoticeQueueApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientIdentityService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeComposer.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/api/ProfitAppropriationConfigApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/service/ProfitAppropriationPostingService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchAccessFilter.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountNotVisibleException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountChargeWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountNoteRecorder.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_current_account_notes.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_create_profit_appropriation_config.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_create_profit_appropriation_posting.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_add_aadhaar_hash_to_client_kyc.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_savings_account_branch_and_client_branch_link.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_add_kyc_reminders_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0605_create_kyc_notice_queue.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_add_ctr_monthly_report_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_rd_base_chart_senior_rate_and_months.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0607_cc_relevant_risk_rules.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_apply_rd_base_chart_to_rd_products.xml diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/Charge.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/Charge.java index 2f8e909a9..f1cbf25f2 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/Charge.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/Charge.java @@ -80,6 +80,7 @@ public class Charge extends AbstractPersistableCustom { @Column(name = "currency_code", length = 3) private String currencyCode; + @Getter @Column(name = "charge_applies_to_enum", nullable = false) private Integer chargeAppliesTo; 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 5eb627eb8..ef79cef0f 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 @@ -42,6 +42,8 @@ public enum JobName { POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), // + SEND_KYC_REMINDERS("Send KYC Reminders"), // + PROCESS_KYC_NOTICE_QUEUE("Process KYC Notice Queue"), // SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), // FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), // diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java index 191a8d7af..8ea8dc0fe 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java @@ -83,15 +83,24 @@ public class SavingsAccountSummaryData { * on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately. */ private final Integer renewalCycleNo; + /** + * The branch this account is held at: the account's own office when it was opened at another branch than the + * client's, else the client's (or the group's) office. Lets a customer with accounts at several branches tell + * them apart on the client page. + */ + private final Long officeId; + private final String officeName; public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId, final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency, final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold, final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline, final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate, - final String fdAccountNumber, final Integer renewalCycleNo) { + final String fdAccountNumber, final Integer renewalCycleNo, final Long officeId, final String officeName) { this.fdAccountNumber = fdAccountNumber; this.renewalCycleNo = renewalCycleNo; + this.officeId = officeId; + this.officeName = officeName; this.id = id; this.accountNo = accountNo; this.externalId = externalId; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java index 911b471ab..d17b9f629 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java @@ -20,14 +20,12 @@ package org.apache.fineract.portfolio.aml.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDate; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.useradministration.domain.AppUser; @@ -40,11 +38,7 @@ import org.apache.fineract.useradministration.domain.AppUser; */ @Entity @Table(name = "m_aml_recurring_review") -public class AmlRecurringReview { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom { @ManyToOne(optional = false) @JoinColumn(name = "client_id", nullable = false) @@ -107,10 +101,6 @@ public class AmlRecurringReview { this.alertsActioned = count; } - public Long getId() { - return id; - } - public Client getClient() { return client; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/AadhaarHash.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/AadhaarHash.java new file mode 100644 index 000000000..55b0018b2 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/AadhaarHash.java @@ -0,0 +1,61 @@ +/** + * 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.client.domain; + +import java.util.regex.Pattern; + +/** + * The comparable fingerprint of an Aadhaar number: the SHA-256 of the 12 digits, as 64 lowercase hex characters. + * + *

+ * The hash is computed by the client (browser) and only the hash is sent. The KYC create/update calls go through the + * command source, which records the request body in an audit table and in the log, so the 12-digit number must not be + * in the body. The server therefore never sees the number and only checks that what it was given has the shape of a + * SHA-256 digest. + * + *

+ * A plain, unkeyed SHA-256 of a 12-digit number can be reversed by trying every possible number, so this column is a + * lookup key, not protection against someone who can read the table. + */ +public final class AadhaarHash { + + private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$"); + + private AadhaarHash() {} + + /** True when the value has the shape of a lowercase hex SHA-256 digest. */ + public static boolean isValid(final String value) { + return value != null && SHA256_HEX.matcher(value).matches(); + } + + /** + * The value trimmed and lower-cased when it is a well-formed digest; null for blank input. A non-blank value that + * is not a digest is rejected rather than silently dropped, so a client bug cannot disable the duplicate check. + */ + public static String normalizeOrNull(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + final String candidate = raw.trim().toLowerCase(); + if (!isValid(candidate)) { + throw new IllegalArgumentException("aadhaarHash must be a 64-character hex SHA-256 digest"); + } + return candidate; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 57d48a9e2..197cb9433 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -151,6 +151,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "aadhaar_number_masked", length = 12) private String aadhaarNumberMasked; + /** + * SHA-256 of the 12-digit Aadhaar, computed client-side (see {@link AadhaarHash}). The masked form cannot tell two + * people apart; this can. Null for rows that predate the column and for clients with no Aadhaar. + */ + @Column(name = "aadhaar_hash", length = 64) + private String aadhaarHash; + @Column(name = "aadhaar_verified", nullable = false) private boolean aadhaarVerified; @@ -391,6 +398,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { command.stringValueOfParameterNamed("aadhaarNumberMasked"), command.stringValueOfParameterNamed("gstinNumber"), command.stringValueOfParameterNamed("remarks")); + kyc.aadhaarHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed("aadhaarHash")); kyc.applyExtendedFields(command); return kyc; } @@ -493,6 +501,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { actualChanges.put(aadhaarParam, newValue); recordOverwrite(aadhaarParam, this.aadhaarNumberMasked); this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null); + // The Aadhaar was removed: the fingerprint of the old number must not outlive it. + if (this.aadhaarNumberMasked == null) { + this.aadhaarHash = null; + } + } + + // Only the fingerprint is sent, never the number. Reported as a flag, not a value, because the changes map + // is echoed back in the API response. + final String aadhaarHashParam = "aadhaarHash"; + if (command.parameterExists(aadhaarHashParam)) { + final String newHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed(aadhaarHashParam)); + if (newHash != null && !newHash.equals(this.aadhaarHash)) { + this.aadhaarHash = newHash; + actualChanges.put("aadhaarIdentityUpdated", true); + } } final String gstinParam = "gstinNumber"; @@ -1050,6 +1073,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { return aadhaarNumberMasked; } + public String getAadhaarHash() { + return aadhaarHash; + } + public boolean isAadhaarVerified() { return aadhaarVerified; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLogRepository.java index e3da179b6..10a208d78 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLogRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLogRepository.java @@ -27,6 +27,13 @@ public interface ClientKycContactLogRepository extends JpaRepository findByClientIdOrderByContactedAtAsc(Long clientId); + /** True when a notice carrying this marker has already reached a final outcome on the channel. */ + boolean existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(Long clientId, KycContactChannel channel, + String markerPrefix, java.util.Collection statuses); + + /** How many times a marked notice has been tried on the channel — bounds the retries of a failing gateway. */ + long countByClientIdAndChannelAndNotesStartingWith(Long clientId, KycContactChannel channel, String markerPrefix); + /** * Every contact log for the given clients in one round trip, so the exceptions grid can render * "last contacted" for the whole page without an N+1 query per row. diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java index 5b506f6d7..fe9e2670b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java @@ -61,6 +61,18 @@ public interface ClientKycRepository extends JpaRepository { + "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC") List findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus); + /** + * KYC records whose re-KYC falls due after {@code asOn} but no later than {@code until}, soonest first. + * + *

The complement of {@link #findLapsedAsOn}: a record is in one list or the other, never both, so the + * two together cover everything that needs the branch's attention. Rejected records are excluded for + * the same reason as there. + */ + @Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate > :asOn " + + "AND k.expiryDate <= :until AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC") + List findExpiringBetween(@Param("asOn") LocalDate asOn, @Param("until") LocalDate until, + @Param("rejectedStatus") Integer rejectedStatus); + // ── Pre-KYC uniqueness probes ─────────────────────────────────────────── // Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the // external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java index b79dbe3d5..65964bc27 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java @@ -110,11 +110,23 @@ public class ClientRiskRule { @Column(name = "rule_kind", length = 10) private String ruleKind; + /** + * Whether this scenario applies to Cash Credit accounts - a current account that is allowed to draw an overdraft + * against a sanctioned limit. Lets the Rules Master show, in one place, every scenario that watches a CC account, + * whatever its coverage status. + */ + @Column(name = "cc_relevant", nullable = false) + private boolean ccRelevant; + public static final String KIND_KYC = "KYC"; public static final String KIND_AML = "AML"; protected ClientRiskRule() {} + public boolean isCcRelevant() { + return ccRelevant; + } + /** * Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category * are attached separately by the caller, which owns the CodeValue lookup. diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java index 7dd1f457d..149c7c909 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java @@ -26,7 +26,8 @@ public interface SearchingClientRepository { /** * @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only. * Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY). + * @param officeId null = no filter; otherwise only clients of that office, still inside officeHierarchy. */ Page searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly, - Integer legalFormValue); + Integer legalFormValue, Long officeId); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java index 8222a1e74..d3d1883eb 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java @@ -53,7 +53,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository @Override public Page searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly, - Integer legalFormValue) { + Integer legalFormValue, Long officeId) { /* * this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at * this point the upgrade is too costly @@ -62,6 +62,9 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository */ String hierarchyLikeValue = officeHierarchy + "%"; + // Customers of other branches that hold an account at the caller's branch are listed here too. + final List linkedClientIds = clientIdsWithAccountAtBranch(hierarchyLikeValue); + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery query = cb.createQuery(SearchedClient.class); @@ -74,7 +77,8 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository Join identity = r.join("identifiers", JoinType.LEFT); List predicates = new ArrayList<>(); - predicates.add(cb.like(o.get("hierarchy"), hierarchyLikeValue)); + final Predicate inHierarchy = cb.like(o.get("hierarchy"), hierarchyLikeValue); + predicates.add(linkedClientIds.isEmpty() ? inHierarchy : cb.or(inHierarchy, r.get("id").in(linkedClientIds))); String searchLikeValue = "%" + searchText + "%"; predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue), @@ -93,6 +97,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository predicates.add(cb.equal(r.get("legalForm"), legalFormValue)); } + // Branch filter. ANDed with the hierarchy predicate above, so it can only narrow results. + if (officeId != null) { + predicates.add(cb.equal(o.get("id"), officeId)); + } + return cb.and(predicates.toArray(new Predicate[0])); }; criteriaQueryFactory.applySpecificationToCriteria(root, spec, query); @@ -108,4 +117,27 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec); } + + /** + * Ids of clients holding an account (savings, fixed or recurring deposit, or current) at an office at or below the + * given hierarchy pattern, whatever office the client itself belongs to. Only accounts opened at a branch other than + * the client's carry their own office, so this is empty for a branch with no such customers, and for Head Office. + * Being linked to a branch is deliberately not enough to be listed there. + */ + @SuppressWarnings("unchecked") + private List clientIdsWithAccountAtBranch(final String hierarchyLikeValue) { + final List rows = entityManager.createNativeQuery( + "SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id " + + "WHERE sa.client_id IS NOT NULL AND ao.hierarchy LIKE ?1 " + + "UNION SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id " + + "WHERE ca.client_id IS NOT NULL AND co.hierarchy LIKE ?2") + // Positional, not named: EclipseLink passes a native query's ":h" to PostgreSQL as written, which fails + // with a syntax error and took the whole client list down with it. + .setParameter(1, hierarchyLikeValue).setParameter(2, hierarchyLikeValue).getResultList(); + final List ids = new ArrayList<>(rows.size()); + for (final Object row : rows) { + ids.add(((Number) row).longValue()); + } + return ids; + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/note/domain/NoteType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/note/domain/NoteType.java index 19710b3bb..90775fcc1 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/note/domain/NoteType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/note/domain/NoteType.java @@ -32,6 +32,7 @@ public enum NoteType { GROUP(600, "noteType.group", "groups", "Group note"), // SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), // SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), // + CURRENT_ACCOUNT(900, "noteType.currentaccount", "currentaccounts", "Current account note"), // ; public static final NoteType[] VALUES = values(); diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountReadPlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountReadPlatformService.java index 18e466395..427021b3c 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountReadPlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountReadPlatformService.java @@ -28,6 +28,14 @@ public interface CurrentAccountReadPlatformService { Map retrieveOneTransaction(Long accountId, Long transactionId); + /** The account's charges from m_current_account_charge; chargeStatus is all, active or inactive. */ + List> retrieveCharges(Long accountId, String chargeStatus); + + Map retrieveCharge(Long accountId, Long accountChargeId); + + /** Template for adding a charge: the active current-account charges in the account's currency. */ + Map retrieveChargeTemplate(Long accountId); + List> retrieveAll(); List> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/api/InterBranchTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/api/InterBranchTransferApiResource.java new file mode 100644 index 000000000..435fab844 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/api/InterBranchTransferApiResource.java @@ -0,0 +1,197 @@ +/** + * 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.interbranch.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Tells a person looking at one ledger transaction whether it is one branch's part of a transfer between branches. + * + *

+ * A transfer between accounts at different branches is posted as one ledger transaction per branch, joined through the + * Liability Transfer account. Looking at either part on its own shows only two ledger lines, with nothing to say it has + * another part in another branch. The bank transfer log records the ledger ids of every part, so given any one of them + * this reports the whole transfer: who it moved money from and to, in which branches, and who raised and approved it. + */ +@Path("/v1/accounting/interbranchtransfers") +@Component +@RequiredArgsConstructor +@Produces(MediaType.APPLICATION_JSON) +public class InterBranchTransferApiResource { + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Path("{transactionId}") + public String retrieve(@PathParam("transactionId") final String transactionId) { + this.context.authenticatedUser().validateHasReadPermission("JOURNALENTRY"); + + // Ledger transaction ids are short alphanumeric strings; anything else cannot be one. + if (transactionId == null || !transactionId.matches("[A-Za-z0-9_-]{1,50}")) { + return notInterBranch(); + } + + final List> logs = this.jdbcTemplate.queryForList( + "SELECT voucher_no, total_amount, gl_txn_id, debit_entries, credit_entries, status FROM m_bank_transfer_log " + + "WHERE (',' || gl_txn_id || ',') LIKE ? ORDER BY id DESC LIMIT 1", + "%," + transactionId + ",%"); + if (logs.isEmpty()) { + return notInterBranch(); + } + final Map log = logs.get(0); + + final List> from = legsOf(log.get("debit_entries")); + final List> to = legsOf(log.get("credit_entries")); + + final Set branchIds = new LinkedHashSet<>(); + for (final Map leg : from) { + branchIds.add(leg.get("branchId")); + } + for (final Map leg : to) { + branchIds.add(leg.get("branchId")); + } + branchIds.remove(null); + if (branchIds.size() < 2) { + return notInterBranch(); + } + + final Map out = new LinkedHashMap<>(); + out.put("interBranch", true); + out.put("voucherNo", log.get("voucher_no")); + out.put("amount", log.get("total_amount")); + out.put("status", log.get("status")); + out.put("thisTransactionId", transactionId); + out.put("from", from); + out.put("to", to); + out.put("parts", partsOf(String.valueOf(log.get("gl_txn_id")))); + + // Raised and approved by: the clerk's request, when the transfer went through approval. + final List> requests = this.jdbcTemplate.queryForList( + "SELECT clerk_name, manager_name, submitted_at, actioned_at FROM m_clerk_disbursement_request WHERE voucher_no = ? " + + "ORDER BY id DESC LIMIT 1", + log.get("voucher_no")); + if (!requests.isEmpty()) { + final Map request = requests.get(0); + out.put("raisedBy", request.get("clerk_name")); + out.put("raisedAt", isoOf(request.get("submitted_at"))); + out.put("approvedBy", request.get("manager_name")); + out.put("approvedAt", isoOf(request.get("actioned_at"))); + } + return this.gson.toJson(out); + } + + private String notInterBranch() { + return "{\"interBranch\":false}"; + } + + /** Each account on one side of the transfer, with the branch its books are in. */ + private List> legsOf(final Object storedJson) { + final List> legs = new ArrayList<>(); + if (storedJson == null) { + return legs; + } + final JsonArray entries = this.gson.fromJson(String.valueOf(storedJson), JsonArray.class); + for (final JsonElement element : entries) { + final JsonObject entry = element.getAsJsonObject(); + final Map leg = new LinkedHashMap<>(); + leg.put("accountNo", text(entry, "accountNo")); + leg.put("accountName", text(entry, "accountName")); + leg.put("amount", text(entry, "amount")); + final Long accountId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull() + ? entry.get("savingsAccountId").getAsLong() + : null; + final Map branch = accountId == null ? null : branchOfAccount(text(entry, "accountType"), accountId); + leg.put("branchId", branch == null ? null : ((Number) branch.get("id")).longValue()); + leg.put("branchName", branch == null ? null : branch.get("name")); + legs.add(leg); + } + return legs; + } + + /** One entry per ledger transaction of the transfer, with the branch it was posted in. */ + private List> partsOf(final String glTxnIds) { + final List> parts = new ArrayList<>(); + for (final String id : glTxnIds.split(",")) { + final String ledgerId = id.trim(); + if (ledgerId.isEmpty()) { + continue; + } + final List> offices = this.jdbcTemplate.queryForList( + "SELECT DISTINCT o.id, o.name FROM acc_gl_journal_entry je JOIN m_office o ON o.id = je.office_id " + + "WHERE je.transaction_id = ?", + ledgerId); + final Map part = new LinkedHashMap<>(); + part.put("transactionId", ledgerId); + part.put("branchId", offices.isEmpty() ? null : ((Number) offices.get(0).get("id")).longValue()); + part.put("branchName", offices.isEmpty() ? null : offices.get(0).get("name")); + parts.add(part); + } + return parts; + } + + /** + * The branch an account's books are in: its own office when it was opened at another branch than the client's, + * otherwise the client's (or group's). A current account is its own table with its own ids. + */ + private Map branchOfAccount(final String accountType, final Long accountId) { + final String sql = "CURRENT".equalsIgnoreCase(accountType) + ? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id " + + "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?" + : "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id " + + "LEFT JOIN m_group g ON g.id = sa.group_id " + + "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?"; + return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null); + } + + private static String text(final JsonObject object, final String name) { + final JsonElement element = object.get(name); + return element == null || element.isJsonNull() ? null : element.getAsString(); + } + + private static String isoOf(final Object value) { + if (value == null) { + return null; + } + if (value instanceof java.sql.Timestamp) { + return ((java.sql.Timestamp) value).toLocalDateTime().toString(); + } + return String.valueOf(value).replace(' ', 'T'); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/service/InterBranchCashService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/service/InterBranchCashService.java new file mode 100644 index 000000000..7f5d135fe --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/interbranch/service/InterBranchCashService.java @@ -0,0 +1,162 @@ +/** + * 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.interbranch.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; +import org.apache.fineract.infrastructure.core.service.DateUtils; +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.Service; + +/** + * Keeps a branch's cash ledger true when a cashier takes or pays cash for an account held at another branch. + * + *

+ * A savings deposit or withdrawal always posts its ledger entries in the branch the account is held at, because that is + * where the account's books are. The cash itself, though, is in the drawer of whichever cashier handled it. When that + * cashier sits at a different branch, the account's branch would show cash it never received (or paid), and the + * cashier's branch would hold cash its ledger does not know about. + * + *

+ * This moves the cash leg to the cashier's branch, through the Liability Transfer account, exactly as a transfer + * between two branches' accounts is booked: the account's branch reverses the cash line against Liability Transfer, and + * the cashier's branch books the same cash line against it. Each branch's books balance on their own and the bank's + * total does not change. A cashier at the account's own branch, or a user who is not a cashier, changes nothing. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class InterBranchCashService { + + private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200; + private static final int CREDIT = 1; + private static final int DEBIT = 2; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + + /** + * @param savingsTransactionId + * the deposit or withdrawal just posted + * @param accountOfficeId + * the branch the account is held at, where that posting was booked + * @param deposit + * true for cash received, false for cash paid out + */ + public void moveCashLegToCashierBranch(final Long savingsTransactionId, final Long accountOfficeId, final String accountNo, + final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) { + final AppUser user = this.context.authenticatedUser(); + if (user.getOffice() == null || accountOfficeId == null || savingsTransactionId == null) { + return; + } + if (!isCashier(user.getId())) { + return; + } + moveCashLegToBranch(savingsTransactionId, accountOfficeId, user.getOffice().getId(), accountNo, amount, transactionDate, deposit); + } + + /** + * Same as {@link #moveCashLegToCashierBranch}, for a posting made by someone other than the cashier who handled the + * cash, such as a manager approving a deposit a cashier took earlier. The branch the cash is in is given. + */ + public void moveCashLegToBranch(final Long savingsTransactionId, final Long accountOfficeId, final Long cashierOfficeId, + final String accountNo, final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) { + final AppUser user = this.context.authenticatedUser(); + if (cashierOfficeId == null || accountOfficeId == null || savingsTransactionId == null + || cashierOfficeId.equals(accountOfficeId)) { + return; + } + + // The cash line of the posting just made: the debit of a deposit, the credit of a withdrawal. + final List> cashLines = this.jdbcTemplate.queryForList( + "SELECT account_id, currency_code FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ? " + + "AND amount = ? AND reversed = false ORDER BY id DESC", + savingsTransactionId, deposit ? DEBIT : CREDIT, amount); + if (cashLines.isEmpty()) { + log.warn("No cash line found for savings transaction {}; its cash was not moved to the cashier's branch", + savingsTransactionId); + return; + } + final Long cashGlId = ((Number) cashLines.get(0).get("account_id")).longValue(); + final String currencyCode = String.valueOf(cashLines.get(0).get("currency_code")); + final Long suspenseGlId = liabilityTransferGlId(); + + final String note = (deposit ? "Inter-branch cash deposit" : "Inter-branch cash withdrawal") + " | A/C " + accountNo + + " | savings txn " + savingsTransactionId + " | cashier " + user.getUsername() + " | via Liability Transfer"; + + // Account's branch: take the cash line back out. A deposit's was Dr cash, so Cr cash; a withdrawal's was Cr cash. + post(accountOfficeId, cashGlId, deposit ? CREDIT : DEBIT, suspenseGlId, deposit ? DEBIT : CREDIT, amount, currencyCode, + transactionDate, user.getId(), note); + // Cashier's branch: the cash really moved here. + post(cashierOfficeId, cashGlId, deposit ? DEBIT : CREDIT, suspenseGlId, deposit ? CREDIT : DEBIT, amount, currencyCode, + transactionDate, user.getId(), note); + } + + private boolean isCashier(final Long userId) { + final Integer count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id " + + "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?", Integer.class, userId); + return count != null && count > 0; + } + + private Long liabilityTransferGlId() { + final List ids = this.jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_LIABILITY_TRANSFER); + if (ids.isEmpty()) { + throw new IllegalStateException("A cash deposit or withdrawal for another branch's account needs the Liability Transfer " + + "account mapped under Accounting > Financial Activity Mappings."); + } + return ids.get(0); + } + + /** One balanced entry in one branch: the cash line and its Liability Transfer counterpart. */ + private void post(final Long officeId, final Long cashGlId, final int cashType, final Long suspenseGlId, final int suspenseType, + final BigDecimal amount, final String currencyCode, final LocalDate entryDate, final long userId, final String note) { + final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.INTER_BRANCH_CASH); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + insert(officeId, cashGlId, cashType, amount, txnId, entryDate, userId, now, currencyCode, note); + insert(officeId, suspenseGlId, suspenseType, amount, txnId, entryDate, userId, now, currencyCode, note); + } + + private void insert(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final long userId, final LocalDateTime now, final String currencyCode, final String description) { + GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId); + final java.time.OffsetDateTime utc = now.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + this.jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, organization_running_balance, submitted_on_date, " + + " transaction_date, created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId, + now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), utc, utc); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java index b8b1f5799..ade79de0f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java @@ -74,12 +74,16 @@ public final class LedgerTxnPrefix { public static final String CASH_MIGRATION = "CSHMIG"; /** Rounding difference on a clerk's day closure. */ public static final String CLOSURE_ROUNDING = "CLSRND"; + /** Cash line of a deposit or withdrawal moved to the cashier's branch, when the account is held at another. */ + public static final String INTER_BRANCH_CASH = "IBCASH"; // ── Year end ────────────────────────────────────────────────────────────── /** Year-end transfer of income into the profit account. */ public static final String YEAR_END_PROFIT = "YEPFT"; /** Year-end closure of an income or expense account. */ public static final String YEAR_END_CLOSE = "YECLS"; + /** Appropriation of a closed year's profit into the reserve funds. */ + public static final String PROFIT_APPROPRIATION = "PRAPP"; // ── Savings ─────────────────────────────────────────────────────────────── /** Pooled savings interest posting for one product and date. */ diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java index 6873aa9eb..f0a082e97 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -366,7 +366,7 @@ public class IncomeProfitTransferService { return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); } - private String resolveCurrency(final Long glAccountId) { + public String resolveCurrency(final Long glAccountId) { try { final String code = jdbcTemplate.queryForObject( "SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class, @@ -377,7 +377,8 @@ public class IncomeProfitTransferService { } } - private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + /** Writes one journal entry leg. Shared with the profit appropriation posting so both year-end steps post alike. */ + public void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) { GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update( 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 73e2f73f4..f59a6e252 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 @@ -238,6 +238,11 @@ public class SecurityConfig { .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE") + // current accounts are deposit accounts: their notes use the savings-note permissions + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE") + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes/*")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE") // notes: create @@ -249,6 +254,8 @@ public class SecurityConfig { .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE") + .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currentaccounts/*/notes")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE") // notes: update @@ -260,6 +267,8 @@ public class SecurityConfig { .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE") + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currentaccounts/*/notes/*")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE") // notes: delete @@ -271,6 +280,8 @@ public class SecurityConfig { .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE") + .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/currentaccounts/*/notes/*")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE") .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE") // document: clients diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java index 32de4e05b..78035114c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java @@ -361,9 +361,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou // Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and // this row's cycle within the chain, so a client's account list can collapse a renewed // FD into one entry rather than listing every term as a separate account. - accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo "); + accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo, "); + // The branch the account is held at: its own office when opened at another branch, else the client's or group's. + accountsSummary.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName "); accountsSummary.append("from m_savings_account sa "); accountsSummary.append("join m_savings_product as p on p.id = sa.product_id "); + accountsSummary.append("left join m_client acct_client on acct_client.id = sa.client_id "); + accountsSummary.append("left join m_group acct_group on acct_group.id = sa.group_id "); + accountsSummary.append( + "left join m_office acct_office on acct_office.id = coalesce(sa.office_id, acct_client.office_id, acct_group.office_id) "); accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id "); accountsSummary.append("join m_currency curr on curr.code = sa.currency_code "); accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid "); @@ -464,9 +470,12 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou final String fdAccountNumber = rs.getString("fdAccountNumber"); final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo"); + final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId"); + final String accountOfficeName = rs.getString("accountOfficeName"); + return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus, - lastActiveTransactionDate, fdAccountNumber, renewalCycleNo); + lastActiveTransactionDate, fdAccountNumber, renewalCycleNo, accountOfficeId, accountOfficeName); } } @@ -496,9 +505,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou sb.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ca.currency_multiplesof as inMultiplesOf, "); sb.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); sb.append("curr.display_symbol as currencyDisplaySymbol, "); - sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName "); + sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName, "); + // The branch the account is held at: its own office, else the client's. + sb.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName "); sb.append("from m_current_account ca "); sb.append("join m_current_product cp on cp.id = ca.product_id "); + sb.append("left join m_client acct_client on acct_client.id = ca.client_id "); + sb.append("left join m_office acct_office on acct_office.id = coalesce(ca.office_id, acct_client.office_id) "); sb.append("join m_currency curr on curr.code = ca.currency_code "); sb.append("left join m_appuser sbu on sbu.id = ca.submittedon_userid "); sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_userid "); @@ -578,10 +591,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400); + final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId"); + final String accountOfficeName = rs.getString("accountOfficeName"); + // Current accounts have no deposit term, so no FD renewal chain to report. return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate, - null, null); + null, null, accountOfficeId, accountOfficeName); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/almreturn/api/AlmMaturityBreakdownApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/almreturn/api/AlmMaturityBreakdownApiResource.java new file mode 100644 index 000000000..43bf215be --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/almreturn/api/AlmMaturityBreakdownApiResource.java @@ -0,0 +1,672 @@ +/** + * 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.almreturn.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.BadRequestException; +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.sql.Date; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +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.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The fixed-term part of the Structural Liquidity Statement: what the bank's customers' Fixed + * Deposits, Recurring Deposits and term loans add up to in each residual-maturity bucket, counted + * from an as-on date. + * + *

How each figure is slotted

+ * + *
    + *
  • Fixed and Recurring Deposits (outflow): the account balance, on the deposit's maturity + * date. RBI slots term deposits by residual maturity; accrued interest is a separate line of the + * statement ("Interest Payable"), so it is not added here. A deposit already at or past its + * maturity is an overdue liability and goes in the first bucket.
  • + *
  • Term loans (inflow): each unpaid instalment's principal, on that instalment's due date - + * RBI asks for the interim cash flows, not the whole loan in one bucket. An instalment already due + * is overdue: one that fell due less than a month ago goes in the "recent" bucket and an older one + * in the "old" bucket. Both bucket positions are parameters, because they are the bank's + * Overdue Receivables rule and not something this code should decide.
  • + *
  • Not slotted by date, reported on their own so they are not silently lost: NPA loans (RBI + * slots them by classification, not by instalment) and cash-credit / overdraft / demand loans (their + * stored schedule is a synthetic equal-instalment one, not a real repayment pattern).
  • + *
+ * + *

What this does not cover

+ * + * The bank's own placements and borrowings - FDs with other banks, investments, borrowings - have no + * register in the system, only ledger balances, so there is nothing to slot by date. The response + * says so rather than leaving them out silently. + * + *

As-on date

+ * + * Accounts are read in their state today. The as-on date only decides where a maturity falls + * relative to it; it does not rebuild what the books looked like on an earlier day. + * + *

Buckets

+ * + * The bucket edges arrive from the caller, since the bank edits them. {@code buckets=14d,28d,3m,6m,1y,3y,5y} + * means the first bucket ends 14 days out, the second 28 days out, the third 3 calendar months out + * and so on, with a last bucket that has no end. Months and years are calendar ones. + */ +@Path("/v1/almreturn/maturity-breakdown") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class AlmMaturityBreakdownApiResource { + + /** Same audience as the other RBI returns, so no separate permission is introduced for it. */ + private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN"; + + /** The bucket edges used when the caller names none: RBI's non-scheduled UCB structure. */ + private static final String DEFAULT_BUCKETS = "14d,28d,3m,6m,1y,3y,5y"; + + private static final int FIXED_DEPOSIT = 200; + private static final int RECURRING_DEPOSIT = 300; + + /** SavingsAccountStatusType ACTIVE and MATURED: a matured deposit is still a liability until closed or renewed. */ + private static final int SAVINGS_ACTIVE = 300; + private static final int SAVINGS_MATURED = 800; + + /** LoanStatus.ACTIVE. */ + private static final int LOAN_ACTIVE = 300; + + private static final Pattern EDGE = Pattern.compile("^(\\d{1,4})([dmy])$"); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + @GET + public String retrieve(@QueryParam("asOnDate") final String asOnDateParam, @QueryParam("officeId") final Long officeId, + @QueryParam("buckets") final String bucketsParam, @QueryParam("overdueRecentBucket") final Integer overdueRecentBucket, + @QueryParam("overdueOldBucket") final Integer overdueOldBucket) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate asOn = parseDate(asOnDateParam); + final List limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam); + final int bucketCount = limits.size() + 1; + final int recent = clampBucket(overdueRecentBucket, 0, bucketCount); + final int old = clampBucket(overdueOldBucket, 2, bucketCount); + final String hierarchy = scopeHierarchy(officeId) + "%"; + + final List> groups = new ArrayList<>(); + groups.add(depositGroup("FD", "Fixed Deposits", FIXED_DEPOSIT, asOn, limits, hierarchy)); + groups.add(depositGroup("RD", "Recurring Deposits", RECURRING_DEPOSIT, asOn, limits, hierarchy)); + groups.add(termLoanGroup(asOn, limits, hierarchy, recent, old)); + + final Map response = new LinkedHashMap<>(); + response.put("asOnDate", asOn.toString()); + response.put("officeId", officeId); + response.put("bucketCount", bucketCount); + response.put("bucketEnds", limits.stream().map(LocalDate::toString).toList()); + response.put("groups", groups); + response.put("notSlotted", List.of(npaLoans(hierarchy), revolvingLoans(hierarchy))); + response.put("notes", List.of( + "Deposits are slotted by their balance on the maturity date; accrued interest belongs to the Interest Payable line.", + "Term loans are slotted instalment by instalment (principal only); accrued interest belongs to the Interest Receivable line.", + "Accounts are read in their state today; the as-on date only decides where a maturity falls.", + "The bank's own FDs with other banks, investments and borrowings have no register, so they are not part of this breakdown.")); + return this.gson.toJson(response); + } + + // ─────────────────────────── Deposits ─────────────────────────── + + private Map depositGroup(final String key, final String label, final int depositType, final LocalDate asOn, + final List limits, final String hierarchy) { + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT datp.maturity_date AS "d", + sa.status_enum AS "st", + COUNT(*) AS "n", + COALESCE(SUM(sa.account_balance_derived), 0) AS "amt" + FROM m_savings_account sa + JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.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_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) + WHERE sa.deposit_type_enum = ? + AND sa.status_enum IN (?, ?) + AND o.hierarchy LIKE ? + GROUP BY datp.maturity_date, sa.status_enum + """, depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy); + + final BigDecimal[] buckets = zeros(limits.size() + 1); + long accounts = 0; + for (final Map row : rows) { + final long n = ((Number) row.get("n")).longValue(); + final BigDecimal amount = (BigDecimal) row.get("amt"); + accounts += n; + final Date maturity = (Date) row.get("d"); + // A MATURED deposit is already past its date and still owed: the first bucket, whatever the date says. + final boolean matured = ((Number) row.get("st")).intValue() == SAVINGS_MATURED; + final int index = matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits); + buckets[index] = buckets[index].add(amount); + } + return group(key, label, "liability", accounts, buckets); + } + + // ─────────────────────────── Loans ─────────────────────────── + + private Map termLoanGroup(final LocalDate asOn, final List limits, final String hierarchy, + final int recentOverdueBucket, final int oldOverdueBucket) { + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT rs.duedate AS "d", + COALESCE(SUM(GREATEST(rs.principal_amount + - COALESCE(rs.principal_completed_derived, 0) + - COALESCE(rs.principal_writtenoff_derived, 0), 0)), 0) AS "amt" + FROM m_loan_repayment_schedule rs + JOIN m_loan l ON l.id = rs.loan_id + LEFT JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_group g ON g.id = l.group_id + JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE l.loan_status_id = ? + AND COALESCE(l.is_npa, false) = false + AND COALESCE(rs.completed_derived, false) = false + AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext + WHERE ext.loan_product_id = l.product_id + AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') + OR LOWER(ext.facility_type) IN ('demand', 'overdraft'))) + AND o.hierarchy LIKE ? + GROUP BY rs.duedate + """, LOAN_ACTIVE, hierarchy); + + final BigDecimal[] buckets = zeros(limits.size() + 1); + final LocalDate recentCutoff = asOn.minusMonths(1); + for (final Map row : rows) { + final LocalDate due = ((Date) row.get("d")).toLocalDate(); + final BigDecimal amount = (BigDecimal) row.get("amt"); + final int index; + if (due.isAfter(asOn)) { + index = bucketIndex(asOn, due, limits); + } else { + // Already due and unpaid. Under a month overdue is still expected shortly; older is not. + index = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket; + } + buckets[index] = buckets[index].add(amount); + } + + final Long loans = this.jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM m_loan l + LEFT JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_group g ON g.id = l.group_id + JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE l.loan_status_id = ? + AND COALESCE(l.is_npa, false) = false + AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext + WHERE ext.loan_product_id = l.product_id + AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') + OR LOWER(ext.facility_type) IN ('demand', 'overdraft'))) + AND o.hierarchy LIKE ? + """, Long.class, LOAN_ACTIVE, hierarchy); + return group("LOAN", "Term Loans (principal instalments)", "asset", loans == null ? 0 : loans, buckets); + } + + /** NPA loans: RBI slots them by classification (sub-standard / doubtful / loss), not by instalment date. */ + private Map npaLoans(final String hierarchy) { + return outstanding("NPA", "NPA loans", """ + SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt" + FROM m_loan l + LEFT JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_group g ON g.id = l.group_id + JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE l.loan_status_id = ? AND COALESCE(l.is_npa, false) = true AND o.hierarchy LIKE ? + """, "Slotted by classification (sub-standard, doubtful, loss), net of provision, not by instalment date.", + hierarchy); + } + + /** CC / OD / demand loans: the stored schedule is synthetic, so the volatile/core split rule applies instead. */ + private Map revolvingLoans(final String hierarchy) { + return outstanding("REVOLVING", "CC / OD / demand loans", """ + SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt" + FROM m_loan l + LEFT JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_group g ON g.id = l.group_id + JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE l.loan_status_id = ? + AND COALESCE(l.is_npa, false) = false + AND EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext + WHERE ext.loan_product_id = l.product_id + AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') + OR LOWER(ext.facility_type) IN ('demand', 'overdraft'))) + AND o.hierarchy LIKE ? + """, "Split into a volatile and a core share by the bank's own rule; the stored instalment schedule is not a real repayment pattern.", + hierarchy); + } + + /** One count-and-total line for loans that are reported apart from the date buckets. */ + private Map outstanding(final String key, final String label, final String sql, final String why, + final String hierarchy) { + final Map row = this.jdbcTemplate.queryForMap(sql, LOAN_ACTIVE, hierarchy); + final Map line = new LinkedHashMap<>(); + line.put("key", key); + line.put("label", label); + line.put("side", "asset"); + line.put("accounts", ((Number) row.get("n")).longValue()); + line.put("outstanding", row.get("amt")); + line.put("why", why); + return line; + } + + // ─────────────────────────── Picking accounts one by one ─────────────────────────── + + /** How many matches one search returns. */ + private static final int SEARCH_LIMIT = 25; + + /** How many accounts one report may cover. */ + private static final int SELECTED_LIMIT = 200; + + /** A loan product whose stored schedule is not a real repayment pattern: cash credit, overdraft, demand. */ + private static final String REVOLVING_PRODUCT = "EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext" + + " WHERE ext.loan_product_id = l.product_id" + + " AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))"; + + /** + * Find fixed deposits, recurring deposits and loans by account number or customer name, so a few of + * them can be picked and broken down by days. + * + * @param kind + * FD, RD or LOAN to look in one kind only; empty to look in all three + */ + @GET + @Path("search") + public String search(@QueryParam("q") final String q, @QueryParam("kind") final String kind, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final String text = q == null ? "" : q.trim().toLowerCase(); + final List> results = new ArrayList<>(); + if (text.length() >= 2) { + final String like = "%" + text + "%"; + final String hierarchy = scopeHierarchy(officeId) + "%"; + final String wanted = kind == null ? "" : kind.trim().toUpperCase(); + if (wanted.isEmpty() || "FD".equals(wanted)) { + results.addAll(searchDeposits(FIXED_DEPOSIT, "FD", like, hierarchy)); + } + if (wanted.isEmpty() || "RD".equals(wanted)) { + results.addAll(searchDeposits(RECURRING_DEPOSIT, "RD", like, hierarchy)); + } + if (wanted.isEmpty() || "LOAN".equals(wanted)) { + results.addAll(searchLoans(like, hierarchy)); + } + } + final Map response = new LinkedHashMap<>(); + response.put("results", results); + return this.gson.toJson(response); + } + + /** + * The day-wise breakdown of the accounts that were picked: each deposit as one row on its maturity + * date, each loan as one row per unpaid instalment on its due date, every row with the days to that + * date from the as-on date and the bucket it falls in. + * + * @param accountsParam + * the picked accounts as KIND:id pairs, for example FD:12,RD:5,LOAN:33 + */ + @GET + @Path("selected") + public String selected(@QueryParam("accounts") final String accountsParam, @QueryParam("asOnDate") final String asOnDateParam, + @QueryParam("officeId") final Long officeId, @QueryParam("buckets") final String bucketsParam, + @QueryParam("overdueRecentBucket") final Integer overdueRecentBucket, + @QueryParam("overdueOldBucket") final Integer overdueOldBucket) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + if (accountsParam == null || accountsParam.isBlank()) { + throw new BadRequestException("accounts is required, like FD:12,RD:5,LOAN:33"); + } + final LocalDate asOn = parseDate(asOnDateParam); + final List limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam); + final int bucketCount = limits.size() + 1; + final String hierarchy = scopeHierarchy(officeId) + "%"; + + final List fixed = new ArrayList<>(); + final List recurring = new ArrayList<>(); + final List loans = new ArrayList<>(); + for (final String token : accountsParam.split(",")) { + final String[] parts = token.trim().split(":"); + if (parts.length != 2) { + throw new BadRequestException("accounts must look like FD:12,RD:5,LOAN:33"); + } + final long id; + try { + id = Long.parseLong(parts[1].trim()); + } catch (final NumberFormatException e) { + throw new BadRequestException("Account id '" + parts[1] + "' is not a number"); + } + switch (parts[0].trim().toUpperCase()) { + case "FD" -> fixed.add(id); + case "RD" -> recurring.add(id); + case "LOAN" -> loans.add(id); + default -> throw new BadRequestException("Account kind '" + parts[0] + "' must be FD, RD or LOAN"); + } + } + if (fixed.size() + recurring.size() + loans.size() > SELECTED_LIMIT) { + throw new BadRequestException("Pick at most " + SELECTED_LIMIT + " accounts at a time"); + } + + final List> rows = new ArrayList<>(); + if (!fixed.isEmpty()) { + rows.addAll(depositRows(FIXED_DEPOSIT, "FD", fixed, asOn, limits, hierarchy)); + } + if (!recurring.isEmpty()) { + rows.addAll(depositRows(RECURRING_DEPOSIT, "RD", recurring, asOn, limits, hierarchy)); + } + if (!loans.isEmpty()) { + rows.addAll(instalmentRows(loans, asOn, limits, hierarchy, clampBucket(overdueRecentBucket, 0, bucketCount), + clampBucket(overdueOldBucket, 2, bucketCount))); + } + // By date, undated rows last, then by account. + rows.sort(Comparator.comparing((Map r) -> String.valueOf(r.get("date"))) + .thenComparing(r -> String.valueOf(r.get("accountNo")))); + + BigDecimal total = BigDecimal.ZERO; + for (final Map row : rows) { + total = total.add((BigDecimal) row.get("amount")); + } + final Map response = new LinkedHashMap<>(); + response.put("asOnDate", asOn.toString()); + response.put("bucketCount", bucketCount); + response.put("count", rows.size()); + response.put("total", total); + response.put("rows", rows); + return this.gson.toJson(response); + } + + private List> searchDeposits(final int depositType, final String kind, final String like, + final String hierarchy) { + final List> found = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "id", + sa.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "customer", + sp.name AS "product", + datp.maturity_date AS "d", + COALESCE(sa.account_balance_derived, 0) AS "amt" + 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_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) + WHERE sa.deposit_type_enum = ? + AND sa.status_enum IN (?, ?) + AND o.hierarchy LIKE ? + AND (LOWER(sa.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?) + ORDER BY sa.account_no + LIMIT ? + """, depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy, like, like, SEARCH_LIMIT); + return hits(found, kind); + } + + private List> searchLoans(final String like, final String hierarchy) { + final List> found = this.jdbcTemplate.queryForList(""" + SELECT l.id AS "id", + l.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "customer", + lp.name AS "product", + COALESCE(l.principal_outstanding_derived, 0) AS "amt" + FROM m_loan l + JOIN m_product_loan lp ON lp.id = l.product_id + LEFT JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_group g ON g.id = l.group_id + JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE l.loan_status_id = ? + AND o.hierarchy LIKE ? + AND (LOWER(l.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?) + ORDER BY l.account_no + LIMIT ? + """, LOAN_ACTIVE, hierarchy, like, like, SEARCH_LIMIT); + return hits(found, "LOAN"); + } + + private static List> hits(final List> found, final String kind) { + final List> rows = new ArrayList<>(); + for (final Map r : found) { + final Date date = (Date) r.get("d"); + final Map row = new LinkedHashMap<>(); + row.put("kind", kind); + row.put("id", r.get("id")); + row.put("accountNo", r.get("accountNo")); + row.put("customer", r.get("customer")); + row.put("product", r.get("product")); + row.put("date", date == null ? null : date.toLocalDate().toString()); + row.put("amount", r.get("amt")); + rows.add(row); + } + return rows; + } + + private static String placeholders(final int count) { + return String.join(",", Collections.nCopies(count, "?")); + } + + private List> depositRows(final int depositType, final String label, final List ids, + final LocalDate asOn, final List limits, final String hierarchy) { + final String sql = """ + SELECT sa.id AS "id", + sa.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "customer", + sp.name AS "product", + datp.maturity_date AS "d", + sa.status_enum AS "st", + COALESCE(sa.account_balance_derived, 0) AS "amt" + 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_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) + WHERE sa.deposit_type_enum = ? + AND sa.status_enum IN (?, ?) + AND o.hierarchy LIKE ? + AND sa.id IN (%s) + ORDER BY datp.maturity_date, sa.account_no + """.formatted(placeholders(ids.size())); + final List args = new ArrayList<>(List.of(depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy)); + args.addAll(ids); + + final List> rows = new ArrayList<>(); + for (final Map r : this.jdbcTemplate.queryForList(sql, args.toArray())) { + final Date maturity = (Date) r.get("d"); + final boolean matured = ((Number) r.get("st")).intValue() == SAVINGS_MATURED; + final Map row = new LinkedHashMap<>(); + row.put("id", r.get("id")); + row.put("accountNo", r.get("accountNo")); + row.put("customer", r.get("customer")); + row.put("product", r.get("product")); + row.put("kind", label); + row.put("date", maturity == null ? null : maturity.toLocalDate().toString()); + row.put("days", maturity == null ? null : ChronoUnit.DAYS.between(asOn, maturity.toLocalDate())); + row.put("status", matured ? "MATURED" : "ACTIVE"); + // Same rule as the totals: a matured deposit is overdue and sits in the first bucket. + row.put("bucket", matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits)); + row.put("amount", r.get("amt")); + rows.add(row); + } + return rows; + } + + /** + * The unpaid instalments of the picked loans. A picked loan is shown by its schedule whatever it is, + * with a note where the schedule is not the whole story (an NPA, or a cash credit / overdraft). + */ + private List> instalmentRows(final List ids, final LocalDate asOn, final List limits, + final String hierarchy, final int recentOverdueBucket, final int oldOverdueBucket) { + final String sql = """ + SELECT l.id AS "id", + l.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "customer", + lp.name AS "product", + rs.installment AS "installment", + rs.duedate AS "d", + GREATEST(rs.principal_amount + - COALESCE(rs.principal_completed_derived, 0) + - COALESCE(rs.principal_writtenoff_derived, 0), 0) AS "amt", + COALESCE(l.is_npa, false) AS "npa", + (%s) AS "revolving" + FROM m_loan_repayment_schedule rs + JOIN m_loan l ON l.id = rs.loan_id + JOIN m_product_loan lp ON lp.id = l.product_id + LEFT JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_group g ON g.id = l.group_id + JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE l.loan_status_id = ? + AND COALESCE(rs.completed_derived, false) = false + AND GREATEST(rs.principal_amount + - COALESCE(rs.principal_completed_derived, 0) + - COALESCE(rs.principal_writtenoff_derived, 0), 0) > 0 + AND o.hierarchy LIKE ? + AND l.id IN (%s) + ORDER BY rs.duedate, l.account_no + """.formatted(REVOLVING_PRODUCT, placeholders(ids.size())); + final List args = new ArrayList<>(List.of(LOAN_ACTIVE, hierarchy)); + args.addAll(ids); + + final LocalDate recentCutoff = asOn.minusMonths(1); + final List> rows = new ArrayList<>(); + for (final Map r : this.jdbcTemplate.queryForList(sql, args.toArray())) { + final LocalDate due = ((Date) r.get("d")).toLocalDate(); + final int bucket; + if (due.isAfter(asOn)) { + bucket = bucketIndex(asOn, due, limits); + } else { + bucket = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket; + } + final boolean npa = Boolean.TRUE.equals(r.get("npa")); + final boolean revolving = Boolean.TRUE.equals(r.get("revolving")); + final Map row = new LinkedHashMap<>(); + row.put("id", r.get("id")); + row.put("accountNo", r.get("accountNo")); + row.put("customer", r.get("customer")); + row.put("product", r.get("product")); + row.put("kind", "LOAN"); + row.put("installment", r.get("installment")); + row.put("date", due.toString()); + row.put("days", ChronoUnit.DAYS.between(asOn, due)); + row.put("status", due.isAfter(asOn) ? "DUE LATER" : "OVERDUE"); + row.put("bucket", bucket); + row.put("amount", r.get("amt")); + row.put("note", npa ? "NPA loan: RBI slots it by classification, not by instalment date" + : revolving ? "CC / OD loan: the stored schedule is not a real repayment pattern" : null); + rows.add(row); + } + return rows; + } + + // ─────────────────────────── Helpers ─────────────────────────── + + private static Map group(final String key, final String label, final String side, final long accounts, + final BigDecimal[] buckets) { + BigDecimal total = BigDecimal.ZERO; + for (final BigDecimal b : buckets) { + total = total.add(b); + } + final Map group = new LinkedHashMap<>(); + group.put("key", key); + group.put("label", label); + group.put("side", side); + group.put("accounts", accounts); + group.put("total", total); + group.put("buckets", List.of(buckets)); + return group; + } + + private static BigDecimal[] zeros(final int size) { + final BigDecimal[] values = new BigDecimal[size]; + java.util.Arrays.fill(values, BigDecimal.ZERO); + return values; + } + + /** Bucket of a maturity: due or overdue goes in the first; otherwise the first bucket whose end it does not pass. */ + static int bucketIndex(final LocalDate asOn, final LocalDate maturity, final List limits) { + if (!maturity.isAfter(asOn)) { + return 0; + } + for (int i = 0; i < limits.size(); i++) { + if (!maturity.isAfter(limits.get(i))) { + return i; + } + } + return limits.size(); + } + + /** "14d,28d,3m" to the dates those edges reach from the as-on date. Calendar months and years; edges must climb. */ + private static List parseLimits(final LocalDate asOn, final String spec) { + final List limits = new ArrayList<>(); + for (final String token : spec.split(",")) { + final Matcher m = EDGE.matcher(token.trim().toLowerCase()); + if (!m.matches() || Integer.parseInt(m.group(1)) < 1) { + throw new BadRequestException("Bucket edge '" + token + "' is not like 14d, 3m or 1y"); + } + final int n = Integer.parseInt(m.group(1)); + final LocalDate limit = switch (m.group(2)) { + case "d" -> asOn.plusDays(n); + case "m" -> asOn.plusMonths(n); + default -> asOn.plusYears(n); + }; + if (!limits.isEmpty() && !limit.isAfter(limits.get(limits.size() - 1))) { + throw new BadRequestException("Bucket edges must run in increasing order: " + spec); + } + limits.add(limit); + } + return limits; + } + + private static int clampBucket(final Integer requested, final int fallback, final int bucketCount) { + final int value = requested == null ? fallback : requested; + return Math.max(0, Math.min(value, bucketCount - 1)); + } + + private static LocalDate parseDate(final String raw) { + if (raw == null || raw.isBlank()) { + return DateUtils.getBusinessLocalDate(); + } + try { + return LocalDate.parse(raw); + } catch (final java.time.format.DateTimeParseException e) { + throw new BadRequestException("asOnDate must be yyyy-MM-dd"); + } + } + + /** The office tree a caller may read: their own, or a branch under it if they asked for one. */ + private String scopeHierarchy(final Long officeId) { + final String own = this.context.authenticatedUser().getOffice().getHierarchy(); + if (officeId == null) { + return own; + } + final List asked = this.jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class, officeId); + return !asked.isEmpty() && asked.get(0).startsWith(own) ? asked.get(0) : own; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCcTrackingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCcTrackingApiResource.java new file mode 100644 index 000000000..ca301bc15 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCcTrackingApiResource.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.aml.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import jakarta.ws.rs.DefaultValue; +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.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; + +/** + * How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared + * and what the bank sanctioned. + * + *

+ * Read straight from the posted current-account transactions, so it works today without the AML engine being fed those + * transactions. A Cash Credit account is a current account allowed to draw an overdraft; every active current account + * is listed, with that flag, so a plain current account is not hidden from view. Accounts are limited to the caller's + * branch hierarchy, and the busiest are listed first. + */ +@Path("/v1/aml/cc-tracking") +@Component +@RequiredArgsConstructor +@Produces(MediaType.APPLICATION_JSON) +public class AmlCcTrackingApiResource { + + /** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */ + private static final String SQL = """ + SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name, + o.name AS branch_name, ca.allow_overdraft, ca.overdraft_limit, ca.account_balance_derived, + COALESCE(NULLIF(c.monthly_turnover, 0), NULLIF(k.declared_annual_turnover, 0) / 12) AS declared_monthly_turnover, + COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 1), 0) AS credits, + COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 2), 0) AS debits, + COUNT(*) FILTER (WHERE t.transaction_type_enum = 1) AS credit_count, + COUNT(*) FILTER (WHERE t.transaction_type_enum = 2) AS debit_count, + COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 1 AND pt.is_cash_payment), 0) AS cash_credits, + COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 2 AND pt.is_cash_payment), 0) AS cash_debits, + MAX(t.transaction_date) AS last_txn_date + FROM m_current_account ca + LEFT JOIN m_client c ON c.id = ca.client_id + LEFT JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) + LEFT JOIN m_client_kyc k ON k.client_id = c.id + LEFT JOIN m_current_account_transaction t ON t.current_account_id = ca.id AND t.is_reversed = false + AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ? + LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id + LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id + WHERE ca.status_enum = 300 + AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?) + GROUP BY ca.id, c.id, o.id, k.id + ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no + """; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) { + final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy(); + final int window = Math.min(Math.max(days, 1), 366); + final LocalDate asOf = DateUtils.getBusinessLocalDate(); + final LocalDate from = asOf.minusDays(window); + + final List> rows = new ArrayList<>(); + int rank = 0; + for (final Map r : this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from), + java.sql.Date.valueOf(asOf), hierarchy + "%")) { + final BigDecimal credits = money(r.get("credits")); + final BigDecimal debits = money(r.get("debits")); + final BigDecimal limit = money(r.get("overdraft_limit")); + final BigDecimal balance = money(r.get("account_balance_derived")); + final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover")); + // What the declared monthly turnover comes to over this window, so a 7-day look is not judged against a month. + final BigDecimal declaredForWindow = declaredMonthly == null ? null + : declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP); + + final Map row = new LinkedHashMap<>(); + row.put("rank", ++rank); + row.put("accountId", r.get("account_id")); + row.put("accountNo", r.get("account_no")); + row.put("clientId", r.get("client_id")); + row.put("clientName", r.get("client_name")); + row.put("branchName", r.get("branch_name")); + row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft"))); + row.put("sanctionedLimit", limit); + row.put("balance", balance); + // How much of the sanctioned limit is drawn: the overdrawn part of the balance over the limit. + row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0 + ? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null); + row.put("declaredMonthlyTurnover", declaredMonthly); + row.put("credits", credits); + row.put("debits", debits); + row.put("net", credits.subtract(debits)); + row.put("creditCount", r.get("credit_count")); + row.put("debitCount", r.get("debit_count")); + row.put("cashCredits", money(r.get("cash_credits"))); + row.put("cashDebits", money(r.get("cash_debits"))); + row.put("cashCreditPercent", credits.signum() > 0 + ? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null); + // Credits and debits as a multiple of what the customer said the business turns over in this window. + row.put("creditsVsDeclared", ratio(credits, declaredForWindow)); + row.put("debitsVsDeclared", ratio(debits, declaredForWindow)); + row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString()); + rows.add(row); + } + + final Map out = new LinkedHashMap<>(); + out.put("asOf", asOf.toString()); + out.put("from", from.toString()); + out.put("days", window); + out.put("accounts", rows); + return this.gson.toJson(out); + } + + private static BigDecimal money(final Object value) { + return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString()); + } + + private static BigDecimal ratio(final BigDecimal amount, final BigDecimal base) { + return base == null || base.signum() <= 0 ? null : amount.divide(base, 2, RoundingMode.HALF_UP); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/job/AmlCtrReportBatchTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/job/AmlCtrReportBatchTasklet.java index 0435f7f07..eb701df8b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/job/AmlCtrReportBatchTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/job/AmlCtrReportBatchTasklet.java @@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.aml.job; import java.time.YearMonth; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.aml.domain.AmlCtrReport; import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService; import org.springframework.batch.core.StepContribution; @@ -30,9 +31,8 @@ import org.springframework.batch.repeat.RepeatStatus; /** * Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical - * threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring - * to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today" - * stance already used for {@code NeftBatchProcessorService}. + * threshold aggregation only. Scheduled for 02:00 on the 1st (changelog 0606), and still available from the + * admin endpoint (see {@code AmlCtrReportApiResource}) for a month run by hand. */ @Slf4j @RequiredArgsConstructor @@ -42,7 +42,9 @@ public class AmlCtrReportBatchTasklet implements Tasklet { @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - final YearMonth previousMonth = YearMonth.now().minusMonths(1); + // The business date, not the wall clock: the month that has just ended is the bank's month, and the two dates + // are not always the same day. + final YearMonth previousMonth = YearMonth.from(DateUtils.getBusinessLocalDate()).minusMonths(1); try { final AmlCtrReport report = generationService.generateForMonth(previousMonth); log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth, 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 436b435ef..93ed98e86 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 @@ -100,6 +100,7 @@ public class CashierDepositRequestApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final VoucherService voucherService; private final FixedDepositFundingValidator fixedDepositFundingValidator; + private final org.apache.fineract.accounting.interbranch.service.InterBranchCashService interBranchCashService; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ── @@ -216,8 +217,9 @@ public class CashierDepositRequestApiResource { @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) { - final StringBuilder where = new StringBuilder("WHERE status = ?"); - final List baseParams = new ArrayList<>(List.of(status)); + // A branch reviews only the requests its own counters raised (and those of branches below it). + final StringBuilder where = new StringBuilder("WHERE status = ? AND " + REQUEST_BRANCH_IN_HIERARCHY); + final List baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")); if (fromDate != null && !fromDate.isBlank()) { where.append(" AND DATE(submitted_at) >= ?"); baseParams.add(Date.valueOf(fromDate)); @@ -246,9 +248,13 @@ public class CashierDepositRequestApiResource { + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); + final List> pageItems = new ArrayList<>(); + for (final Map row : rows) { + pageItems.add(withBranches(new java.util.LinkedHashMap<>(row))); + } final JsonObject result = new JsonObject(); result.addProperty("totalFilteredRecords", total); - result.add("pageItems", gson.toJsonTree(rows)); + result.add("pageItems", gson.toJsonTree(pageItems)); return gson.toJson(result); } @@ -313,7 +319,82 @@ public class CashierDepositRequestApiResource { @Path("/{id}") @Produces(MediaType.APPLICATION_JSON) public String getById(@PathParam("id") final Long id) { - return gson.toJson(loadRecord(id)); + final Map record = loadRecord(id); + assertRequestInCallersBranch(record); + final Map detail = new java.util.LinkedHashMap<>(); + record.forEach((key, value) -> detail.put(String.valueOf(key), value)); + return gson.toJson(withBranches(detail)); + } + + /** + * Adds the two branches a deposit request involves: where the cash was taken (the cashier's counter) and where the + * account is held. When they differ the request is an inter-branch deposit, which the reviewer must be able to see. + */ + private Map withBranches(final Map row) { + final Object requestId = row.get("id"); + final List> counter = jdbcTemplate.queryForList( + "SELECT o.id, o.name FROM m_cashier_deposit_request r LEFT JOIN m_appuser au ON au.id = r.cashier_id " + + "JOIN m_office o ON o.id = COALESCE(r.counter_office_id, au.office_id) WHERE r.id = ?", requestId); + final List> account = jdbcTemplate.queryForList(""" + SELECT o.id, o.name FROM m_cashier_deposit_request r + LEFT JOIN m_savings_account sa ON sa.id = r.savings_account_id AND sa.account_no = r.account_no + LEFT JOIN m_client sc ON sc.id = sa.client_id + LEFT JOIN m_current_account ca ON ca.id = r.savings_account_id AND ca.account_no = r.account_no + LEFT JOIN m_client cc ON cc.id = ca.client_id + JOIN m_office o ON o.id = COALESCE(CASE WHEN r.account_type = 'GL' THEN r.office_id END, + sa.office_id, sc.office_id, ca.office_id, cc.office_id) + WHERE r.id = ? + """, requestId); + final Long counterId = counter.isEmpty() ? null : ((Number) counter.get(0).get("id")).longValue(); + final Long accountId = account.isEmpty() ? null : ((Number) account.get(0).get("id")).longValue(); + row.put("counter_office_name", counter.isEmpty() ? null : counter.get(0).get("name")); + row.put("account_office_name", account.isEmpty() ? null : account.get(0).get("name")); + row.put("counter_branch_id", counterId); + row.put("account_branch_id", accountId); + row.put("inter_branch", counterId != null && accountId != null && !counterId.equals(accountId)); + return row; + } + + /** + * A cash deposit posts in the account's branch, but the cash is in the drawer of the cashier who took it. When those + * are different branches, moves the cash line of the posting to the counter's branch through Liability Transfer. + * Only a cash payment has a cash line, and only a savings-table deposit (savings, FD, RD) is covered here. + */ + private void moveCashToCounterBranch(final Map record, final String txnId, final BigDecimal amount, final String txnDateStr) { + final Object paymentType = record.get("payment_type_name"); + final Long savingsTxnId = parseTxnIdToLong(txnId); + if (savingsTxnId == null || paymentType == null || !"cash".equalsIgnoreCase(paymentType.toString().trim())) { + return; + } + final Map seed = new java.util.LinkedHashMap<>(); + seed.put("id", record.get("id")); + final Map branches = withBranches(seed); + final Long counterId = (Long) branches.get("counter_branch_id"); + final Long accountId = (Long) branches.get("account_branch_id"); + if (counterId == null || accountId == null || counterId.equals(accountId)) { + return; + } + final java.time.LocalDate date = txnDateStr != null && !txnDateStr.isBlank() ? java.time.LocalDate.parse(txnDateStr) + : DateUtils.getBusinessLocalDate(); + interBranchCashService.moveCashLegToBranch(savingsTxnId, accountId, counterId, String.valueOf(record.get("account_no")), amount, + date, true); + } + + /** + * Rows whose branch (the counter that took the cash; the cashier's own office for requests raised before that was + * recorded) is at or below the caller's office. Takes the caller's hierarchy plus "%" as its one parameter. + */ + private static final String REQUEST_BRANCH_IN_HIERARCHY = "COALESCE(counter_office_id, " + + "(SELECT au.office_id FROM m_appuser au WHERE au.id = cashier_id)) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)"; + + /** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */ + private void assertRequestInCallersBranch(final Map record) { + final Integer visible = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_cashier_deposit_request WHERE id = ? AND " + REQUEST_BRANCH_IN_HIERARCHY, Integer.class, + ((Number) record.get("id")).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%"); + if (visible == null || visible == 0) { + throw new NotFoundException("Deposit request not found: " + record.get("id")); + } } // ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ── @@ -400,6 +481,7 @@ public class CashierDepositRequestApiResource { final AppUser currentUser = context.authenticatedUser(); validateCheckerRole(currentUser); final Map record = loadRecord(id); + assertRequestInCallersBranch(record); if (!"PENDING".equals(record.get("status"))) { throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status")); @@ -457,6 +539,7 @@ public class CashierDepositRequestApiResource { default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); }; if (!isCurrentAccount && txnId != null) { + moveCashToCounterBranch(record, txnId, amount, txnDateStr); // approve() is one @Transactional block, so an uncaught exception here would roll back // the deposit that was just posted along with it -- every Cheque/DD approval that ever // hit an edge case in the new hold-placement logic would fail entirely, where before @@ -537,6 +620,7 @@ public class CashierDepositRequestApiResource { final AppUser currentUser = context.authenticatedUser(); validateCheckerRole(currentUser); final Map record = loadRecord(id); + assertRequestInCallersBranch(record); if (!"PENDING".equals(record.get("status"))) { throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status")); 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 9a02c384d..e6db6c1e9 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 @@ -577,11 +577,11 @@ public class ShareAdvanceCollectionApiResource { } // A separately-verified entry-fee receipt, when the member's fee was collected as its own - // receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry. - // This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL - // entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave - // null) when the fee was paid as part of the same Sundry receipt(s) as the share value -- - // that legacy, single-receipt case still needs the fee amount swept out of Sundry below. + // receipt into whatever GL account the cashier actually deposited it to. This id proves the + // fee was paid and never contributes to the share-value leg below. If the cashier banked it + // into an income account it is left there; anywhere else (Sundry, most often) it is moved to + // Share Entry Fee Income below. Omit it (leave null) when the fee was paid as part of the same + // Sundry receipt(s) as the share value -- that single-receipt case is swept out of Sundry below. final Long chargeCollectionId = getLong(json, "chargeCollectionId"); final List> collections = new java.util.ArrayList<>(); @@ -748,6 +748,20 @@ public class ShareAdvanceCollectionApiResource { insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share entry fee for " + accountNo); } + } else { + // A separate fee receipt is only already income if the cashier banked it into an income + // account. One deposited into Sundry (or any other non-income account) sat there for + // good: nothing here moved it, so the year-end close never swept it into profit. Move + // the fee out of wherever it actually sits; any over-payment on that receipt stays put, + // as before. + final Long feeReceiptGl = receiptGlOf((String) chargeColl.get("gl_transaction_id"), glSuspense); + if (!isIncomeGl(feeReceiptGl)) { + final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); + insertEntry(officeId, feeReceiptGl, DEBIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share entry fee settled | Receipt " + chargeColl.get("receipt_no") + " | " + accountNo); + insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share entry fee for " + accountNo); + } } jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); 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 1954371b3..9145bf9b1 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 @@ -239,7 +239,11 @@ public class ChequeBookIssuanceChargeService { final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId) : getCurrentControlGlId(accountId); - final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId() : null; + // A charge that names no income GL of its own falls back to the product's Income From Fees, the + // same order the savings charge bridge follows. Facility charges are seeded with no GL, so without + // this every cheque book was issued free and the fee never reached income or the year's profit. + final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId() + : getProductFeeIncomeGlId(accountType, accountId); if (ownAccountGlId == null || incomeGlId == null) { log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); " + "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId); @@ -345,6 +349,19 @@ public class ChequeBookIssuanceChargeService { return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } + /** The account's product's Income From Fees mapping (financial_account_type 4), or null if the product maps none. */ + private Long getProductFeeIncomeGlId(final ChequeAccountType accountType, final Long accountId) { + final String sql = accountType == ChequeAccountType.SAVINGS + ? "SELECT apm.gl_account_id FROM m_savings_account sa JOIN acc_product_mapping apm ON apm.product_id = sa.product_id " + + "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL " + + "AND apm.payment_type IS NULL LIMIT 1" + : "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 = 6 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL " + + "AND apm.payment_type IS NULL LIMIT 1"; + final List> rows = this.jdbcTemplate.queryForList(sql, accountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); + } + /** 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, 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 c5c065e36..4c4496c1a 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 @@ -305,6 +305,10 @@ public class ClerkDisbursementRequestApiResource { final StringBuilder where = new StringBuilder("WHERE status = ?"); final List baseParams = new ArrayList<>(List.of(status)); + // A request belongs to the branch it was raised at. A checker sees their own branch's requests and those of the + // branches below it; Head Office, the root, sees every branch's. + where.append(" AND office_id IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)"); + baseParams.add(currentUser.getOffice().getHierarchy() + "%"); if (fromDate != null && !fromDate.isBlank()) { try { where.append(" AND DATE(submitted_at) >= ?"); @@ -431,8 +435,63 @@ public class ClerkDisbursementRequestApiResource { if (!currentUser.getId().equals(clerkId)) { throw apiError(403, "You can only view your own disbursement requests."); } + } else { + assertRequestInCallersBranch(record, currentUser); } - return gson.toJson(record); + return gson.toJson(withLegBranches(record)); + } + + /** + * The request as stored, with the branch each account leg is held at added to its entries. + * + *

+ * The approver has to see when money moves between branches, and the stored entries carry only the account number + * and name. The branch is looked up now, from the account, rather than stored at submission: a request raised before + * this existed still shows it, and it is the branch the account is in at the moment of approval that matters. A + * leg with no account behind it (a GL leg) gets none, and a failure here leaves the entries exactly as stored, so + * the review screen never fails over it. + */ + private Map withLegBranches(final Map record) { + final Map out = new java.util.LinkedHashMap<>(); + record.forEach((key, value) -> out.put(String.valueOf(key), value)); + final Object stored = out.get("credit_entries"); + if (!(stored instanceof String) || ((String) stored).isBlank()) { + return out; + } + try { + final JsonArray entries = gson.fromJson((String) stored, JsonArray.class); + for (final JsonElement element : entries) { + final JsonObject entry = element.getAsJsonObject(); + final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; + if ("GL".equals(accountType) || !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) { + continue; + } + final Map branch = branchOfAccount(accountType, entry.get("savingsAccountId").getAsLong()); + if (branch != null) { + entry.addProperty("officeId", ((Number) branch.get("id")).longValue()); + entry.addProperty("officeName", String.valueOf(branch.get("name"))); + } + } + out.put("credit_entries", gson.toJson(entries)); + } catch (final RuntimeException e) { + log.warn("Could not add account branches to clerk request {}: {}", record.get("id"), e.getMessage()); + } + return out; + } + + /** + * The branch an account's books are in: its own office when it was opened at another branch than the client's, + * otherwise the client's (or group's). A CURRENT leg carries an id in m_current_account's id space; every other + * non-GL type is a m_savings_account row. + */ + private Map branchOfAccount(final String accountType, final Long accountId) { + final String sql = "CURRENT".equalsIgnoreCase(accountType) + ? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id " + + "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?" + : "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id " + + "LEFT JOIN m_group g ON g.id = sa.group_id " + + "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?"; + return jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null); } // ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ───── @@ -448,6 +507,7 @@ public class ClerkDisbursementRequestApiResource { final AppUser currentUser = context.authenticatedUser(); validateCheckerRole(currentUser); final Map record = loadRecord(id); + assertRequestInCallersBranch(record, currentUser); if (!"PENDING".equals(record.get("status"))) { throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status")); @@ -578,21 +638,23 @@ public class ClerkDisbursementRequestApiResource { "No savings-control GL mapping found for account " + acNo + ". Check acc_product_mapping (product_type=2, financial_account_type=2)."); + // Each leg is booked in the branch its own account is held at, not the request's branch. + final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId); if ("Dr".equals(dbCr)) { final long txnId = isCurrent - ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) - : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); + ? directDebitCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description) + : directDebitSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); + btDebitGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId)); btTotalDr = btTotalDr.add(entryAmt); if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); } else { final long txnId = isCurrent - ? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) - : directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); + ? directCreditCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description) + : directCreditSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); + btCreditGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId)); if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); } @@ -602,13 +664,10 @@ public class ClerkDisbursementRequestApiResource { if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) { final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description) + " → " + buildGlEntryComment("", btCreditGlEntries, null); - final String btGlTxnId = postCombinedJournalEntry( - btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment); - if (btGlTxnId != null) { - linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2); - linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1); - } - approvedGlTxnId = btGlTxnId; + final String btGlTxnId = postBankTransferJournals( + btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps); + // The request row keeps 60 characters; an inter-branch transfer posts one entry per branch. + approvedGlTxnId = btGlTxnId == null || btGlTxnId.length() <= 60 ? btGlTxnId : btGlTxnId.split(",")[0]; // Audit log in m_bank_transfer_log final String btTxnRefsStr = String.join(",", txnRefs); @@ -855,6 +914,7 @@ public class ClerkDisbursementRequestApiResource { final AppUser currentUser = context.authenticatedUser(); validateCheckerRole(currentUser); final Map record = loadRecord(id); + assertRequestInCallersBranch(record, currentUser); // Maker-checker: the person who submitted the request cannot also reject it. final Long makerId = record.get("clerk_id") != null @@ -1005,12 +1065,14 @@ public class ClerkDisbursementRequestApiResource { "No savings-control GL mapping found for account " + acNo + ". Check acc_product_mapping (product_type=2, financial_account_type=2)."); + // Each leg is booked in the branch its own account is held at, not the request's branch. + final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId); final long txnId = isCurrent - ? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) - : directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); + ? directDebitCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description) + : directDebitSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); + debitGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId)); if (isCurrent) { undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); } else { @@ -1032,11 +1094,12 @@ public class ClerkDisbursementRequestApiResource { "No savings-control GL mapping found for account " + acNo + ". Check acc_product_mapping (product_type=2, financial_account_type=2)."); + final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId); final long txnId = isCurrent - ? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) - : directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); + ? directCreditCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description) + : directCreditSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); + creditGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId)); if (isCurrent) { undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); } else { @@ -1047,13 +1110,8 @@ public class ClerkDisbursementRequestApiResource { // ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─ final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description) + " → " + buildGlEntryComment("", creditGlEntries, null); - glTxnId = postCombinedJournalEntry( - debitGlEntries, creditGlEntries, - officeId, currencyCode, txnDateStr, btComment); - if (glTxnId != null) { - linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2); - linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1); - } + glTxnId = postBankTransferJournals( + debitGlEntries, creditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps); } catch (Exception ex) { for (int i = undoOps.size() - 1; i >= 0; i--) { @@ -1446,6 +1504,28 @@ public class ClerkDisbursementRequestApiResource { } } + /** + * A request belongs to the branch it was raised at, and is the business of that branch's checker, not another's. + * A checker may see and act on requests of their own branch and the branches below it; Head Office, as the root, + * reaches every branch. Anything outside that is reported as not found rather than forbidden, so it does not even + * confirm the request exists. A request with no branch on record belongs to the root. + */ + private void assertRequestInCallersBranch(final Map record, final AppUser user) { + final String callerHierarchy = user.getOffice().getHierarchy(); + final Object officeId = record.get("office_id"); + boolean visible; + if (officeId == null) { + visible = ".".equals(callerHierarchy); + } else { + final String requestHierarchy = jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class, + ((Number) officeId).longValue()).stream().findFirst().orElse(null); + visible = requestHierarchy != null && requestHierarchy.startsWith(callerHierarchy); + } + if (!visible) { + throw new NotFoundException("Disbursement request not found: " + record.get("id")); + } + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount, @@ -1690,6 +1770,8 @@ public class ClerkDisbursementRequestApiResource { final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL 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 + /** The branch this leg's account is held at; null where it was not resolved (the request's branch is then used). */ + Long officeId; GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId) { @@ -2123,6 +2205,105 @@ public class ClerkDisbursementRequestApiResource { return result != null ? result.getTransactionId() : null; } + /** Financial Activity "Liability Transfer": the account that links branches' books on a transfer between them. */ + private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200; + + /** The branch an account's books are in, or {@code fallbackOfficeId} where it cannot be resolved. */ + private Long legOfficeOf(final boolean isCurrent, final Long accountId, final Long fallbackOfficeId) { + final Map branch = branchOfAccount(isCurrent ? "CURRENT" : "SAVINGS", accountId); + return branch != null ? ((Number) branch.get("id")).longValue() : fallbackOfficeId; + } + + private static GlEntry withOffice(final GlEntry entry, final Long officeId) { + entry.officeId = officeId; + return entry; + } + + private Long liabilityTransferGlId() { + final List ids = jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", + Long.class, FINANCIAL_ACTIVITY_LIABILITY_TRANSFER); + if (ids.isEmpty()) { + throw new RuntimeException("A transfer between branches needs the Liability Transfer account mapped under " + + "Accounting > Financial Activity Mappings."); + } + return ids.get(0); + } + + private static BigDecimal totalOf(final List entries) { + BigDecimal total = BigDecimal.ZERO; + for (final GlEntry entry : entries) { + total = total.add(entry.amount); + } + return total; + } + + /** + * Posts the ledger side of a bank transfer and returns its transaction id, or the ids comma-separated when more than + * one entry is needed. + * + *

+ * Every leg belongs to the branch its own account is held at. When they all share one branch this is the single + * combined entry it always was, booked in that branch. When the legs span branches, each branch gets its own entry, + * balanced through the Liability Transfer account: a branch that pays out more than it takes in credits the difference + * to it, one that takes in more debits it. The two sides net to zero across the bank, so each branch's books balance + * by themselves and the bank's total does not move. Each entry registers its own undo, so a failure later in the + * transfer rolls the ledger back with the accounts. + */ + private String postBankTransferJournals(final List debits, final List credits, final Long fallbackOfficeId, + final String currencyCode, final String txnDateStr, final String comment, final List undoOps) { + final Map> debitsByOffice = new java.util.LinkedHashMap<>(); + final Map> creditsByOffice = new java.util.LinkedHashMap<>(); + for (final GlEntry debit : debits) { + debitsByOffice.computeIfAbsent(debit.officeId != null ? debit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(debit); + } + for (final GlEntry credit : credits) { + creditsByOffice.computeIfAbsent(credit.officeId != null ? credit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(credit); + } + final Set offices = new LinkedHashSet<>(debitsByOffice.keySet()); + offices.addAll(creditsByOffice.keySet()); + + final List txnIds = new ArrayList<>(); + if (offices.size() == 1) { + postBranchEntry(debits, credits, offices.iterator().next(), currencyCode, txnDateStr, comment, undoOps, txnIds); + } else { + final Long suspenseGlId = liabilityTransferGlId(); + for (final Long office : offices) { + final List branchDebits = new ArrayList<>(debitsByOffice.getOrDefault(office, List.of())); + final List branchCredits = new ArrayList<>(creditsByOffice.getOrDefault(office, List.of())); + final BigDecimal net = totalOf(branchCredits).subtract(totalOf(branchDebits)); + if (net.signum() > 0) { + branchDebits.add(new GlEntry(suspenseGlId, net, "Inter-branch", null)); + } else if (net.signum() < 0) { + branchCredits.add(new GlEntry(suspenseGlId, net.negate(), "Inter-branch", null)); + } + postBranchEntry(branchDebits, branchCredits, office, currencyCode, txnDateStr, + comment + " | Inter-branch, via Liability Transfer", undoOps, txnIds); + } + } + return txnIds.isEmpty() ? null : String.join(",", txnIds); + } + + private void postBranchEntry(final List debits, final List credits, final Long officeId, final String currencyCode, + final String txnDateStr, final String comment, final List undoOps, final List txnIds) { + final String glTxnId = postCombinedJournalEntry(debits, credits, officeId, currencyCode, txnDateStr, comment); + if (glTxnId == null) { + return; + } + linkGlEntriesToSavingsAccounts(glTxnId, debits, 2); + linkGlEntriesToSavingsAccounts(glTxnId, credits, 1); + txnIds.add(glTxnId); + undoOps.add(() -> reverseLedgerEntry(glTxnId)); + } + + /** Reverses a ledger entry this request posted, when a later step of the same transfer fails. */ + private void reverseLedgerEntry(final String glTxnId) { + final CommandWrapper cmd = new CommandWrapperBuilder().reverseJournalEntry(glTxnId) + .withJson("{\"comments\":\"Bank transfer rolled back\"}").build(); + fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); + } + /** Builds the GL entry comments string listing all affected account numbers. */ private String buildGlEntryComment(final String mode, final List entries, final String description) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index 5495eee7b..d15782c00 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -351,8 +351,8 @@ public class ClerkClosureRequestApiResource { @QueryParam("toDate") final String toDate, @QueryParam("search") final String search) { - final StringBuilder where = new StringBuilder("WHERE status = ?"); - final List baseParams = new ArrayList<>(List.of(status)); + final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY); + final List baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")); if (fromDate != null && !fromDate.isBlank()) { where.append(" AND DATE(submitted_at) >= ?"); baseParams.add(Date.valueOf(fromDate)); @@ -1071,7 +1071,28 @@ public class ClerkClosureRequestApiResource { } } + /** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */ + private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN " + + "(SELECT id FROM m_office WHERE hierarchy LIKE ?)"; + + /** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */ + private void assertMakerInCallersBranch(final Map record) { + final Object makerId = record.get("clerk_id"); + final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?", + Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%"); + if (visible == null || visible == 0) { + throw new NotFoundException("Request not found: " + record.get("id")); + } + } + private Map loadRecord(final Long id) { + final Map record = loadRecordUnscoped(id); + assertMakerInCallersBranch(record); + return record; + } + + private Map loadRecordUnscoped(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, savings_account_id, account_no, client_id, client_name, closure_date, payment_type_id, payment_type_name, description, tok_no, status, 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 2b4bb563e..2e25d7f31 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 @@ -265,7 +265,9 @@ public class ClerkShareWithdrawalRequestApiResource { @QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, @QueryParam("search") final String search) { - return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit); + return queryList("WHERE status = ? AND " + MAKER_IN_HIERARCHY, + new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")), fromDate, toDate, search, + offset, limit); } // ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ────── @@ -1441,7 +1443,29 @@ public class ClerkShareWithdrawalRequestApiResource { } } + /** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */ + private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN " + + "(SELECT id FROM m_office WHERE hierarchy LIKE ?)"; + + /** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */ + private void assertMakerInCallersBranch(final Map record) { + final Object makerId = record.get("clerk_id"); + final Integer visible = makerId == null ? 0 + : jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?", + Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%"); + if (visible == null || visible == 0) { + throw new NotFoundException("Request not found: " + record.get("id")); + } + } + private Map loadRecord(final Long id) { + final Map record = loadRecordUnscoped(id); + assertMakerInCallersBranch(record); + return record; + } + + private Map loadRecordUnscoped(final Long id) { final List> rows = jdbcTemplate .queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id); if (rows.isEmpty()) { 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 e18c3092d..f507f58dd 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 @@ -188,8 +188,8 @@ public class ClerkWithdrawalRequestApiResource { @QueryParam("toDate") final String toDate, @QueryParam("search") final String search) { - final StringBuilder where = new StringBuilder("WHERE status = ?"); - final List baseParams = new ArrayList<>(List.of(status)); + final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY); + final List baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")); if (fromDate != null && !fromDate.isBlank()) { where.append(" AND DATE(submitted_at) >= ?"); baseParams.add(Date.valueOf(fromDate)); @@ -308,6 +308,7 @@ public class ClerkWithdrawalRequestApiResource { if (rows.isEmpty()) { throw new NotFoundException("Withdrawal request not found: " + id); } + assertMakerInCallersBranch(rows.get(0)); return gson.toJson(rows.get(0)); } @@ -942,7 +943,28 @@ public class ClerkWithdrawalRequestApiResource { .orElse(Map.of()); } + /** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */ + private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN " + + "(SELECT id FROM m_office WHERE hierarchy LIKE ?)"; + + /** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */ + private void assertMakerInCallersBranch(final Map record) { + final Object makerId = record.get("clerk_id"); + final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?", + Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%"); + if (visible == null || visible == 0) { + throw new NotFoundException("Request not found: " + record.get("id")); + } + } + private Map loadRecord(final Long id) { + final Map record = loadRecordUnscoped(id); + assertMakerInCallersBranch(record); + return record; + } + + private Map loadRecordUnscoped(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientIdentityLookupApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientIdentityLookupApiResource.java new file mode 100644 index 000000000..4699ef6d8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientIdentityLookupApiResource.java @@ -0,0 +1,199 @@ +/** + * 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.client.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +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.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.service.ClientIdentityService; +import org.apache.fineract.portfolio.client.service.ClientIdentityService.IdentityMatch; +import org.apache.fineract.portfolio.savings.security.AccountBranchService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * "Is this person already a customer of the bank?", asked from the General step of client creation before anything is + * saved. See {@link ClientIdentityService} for the rules. + * + *

+ * It is a plain POST resource rather than a command, so the request body is not written to the command-source audit + * table. The Aadhaar arrives only as its SHA-256; the 12-digit number never reaches the server. + */ +@Path("/v1/clientidentity") +@Component +@Tag(name = "Client Identity", description = "Cross-branch duplicate-customer lookup by PAN, Aadhaar fingerprint or GSTIN.") +@RequiredArgsConstructor +public class ClientIdentityLookupApiResource { + + private static final Logger LOG = LoggerFactory.getLogger(ClientIdentityLookupApiResource.class); + private static final Gson GSON = new Gson(); + + private final PlatformSecurityContext context; + private final ClientIdentityService clientIdentityService; + private final FromJsonHelper fromJsonHelper; + private final AccountBranchService accountBranchService; + + /** + * Introduces a customer of another branch to the caller's branch, so the branch can open an account for them + * there. The customer stays at the home branch; this records who introduced them to which branch and when. + */ + @POST + @Path("link") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Link an existing customer of another branch to the caller's branch", + description = "Body: { clientId }. Idempotent. Lets the caller's branch open an account for the customer.") + public String link(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "CREATE_SAVINGSACCOUNT")) { + throw new NoAuthorizationException("The user doesn't have enough permissions to link a customer to a branch."); + } + final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}")); + final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject(); + if (!body.has("clientId") || body.get("clientId").isJsonNull()) { + throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("error.msg.clientidentity.clientId.required", + "clientId is required.", "clientId"))); + } + final Long clientId = body.get("clientId").getAsLong(); + final boolean newlyLinked = this.accountBranchService.linkClientToCallerBranch(clientId); + + final Map response = new LinkedHashMap<>(); + response.put("clientId", clientId); + response.put("linked", newlyLinked); + response.put("officeId", this.context.authenticatedUser().getOffice().getId()); + response.put("officeName", this.context.authenticatedUser().getOffice().getName()); + return GSON.toJson(response); + } + + @POST + @Path("lookup") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Look up an existing customer by PAN, Aadhaar hash or GSTIN, and check phone/email clashes", + description = "Body: { panNumber?, aadhaarHash?, gstinNumber?, mobileNo?, emailAddress?, excludeClientId? }. " + + "Any ONE of PAN / Aadhaar hash / GSTIN matching an existing client means the same customer. A phone or " + + "email clash is reported separately under contactConflicts and is never the same customer.") + public String lookup(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "UPDATE_CLIENT")) { + throw new NoAuthorizationException("The user doesn't have enough permissions to look up customers."); + } + + final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}")); + final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject(); + + final String pan = text(body, "panNumber"); + final String aadhaarHash = text(body, "aadhaarHash"); + final String gstin = text(body, "gstinNumber"); + final String mobileNo = text(body, "mobileNo"); + final String emailAddress = text(body, "emailAddress"); + final Long excludeClientId = body.has("excludeClientId") && !body.get("excludeClientId").isJsonNull() + ? body.get("excludeClientId").getAsLong() : null; + + ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHash); + + final List matches = this.clientIdentityService.findMatches(pan, aadhaarHash, gstin, excludeClientId); + final Map contactConflicts = this.clientIdentityService.contactConflicts(mobileNo, emailAddress, + excludeClientId); + + // Who looked up what, never the values: a lookup that finds a customer at another branch is exactly what + // someone browsing for customers would do, so there has to be a trail. + LOG.info("client identity lookup: user={} fields={} matchedClientIds={}", this.context.authenticatedUser().getUsername(), + suppliedFields(pan, aadhaarHash, gstin, mobileNo, emailAddress), matches.stream().map(IdentityMatch::clientId).toList()); + + final Set distinctClients = new LinkedHashSet<>(); + matches.forEach(m -> distinctClients.add(m.clientId())); + + final Map response = new LinkedHashMap<>(); + response.put("matched", !matches.isEmpty()); + // PAN points at one customer and Aadhaar (or GSTIN) at another: bad data or fraud, not a customer to reuse. + response.put("conflict", distinctClients.size() > 1); + response.put("matches", toJsonList(matches)); + response.put("contactConflicts", contactConflicts); + return GSON.toJson(response); + } + + private static String text(final JsonObject body, final String name) { + final JsonElement element = body.get(name); + return element == null || element.isJsonNull() ? null : StringUtils.trimToNull(element.getAsString()); + } + + private static List suppliedFields(final String pan, final String aadhaarHash, final String gstin, final String mobileNo, + final String emailAddress) { + final List fields = new ArrayList<>(); + if (pan != null) { + fields.add("pan"); + } + if (aadhaarHash != null) { + fields.add("aadhaar"); + } + if (gstin != null) { + fields.add("gstin"); + } + if (mobileNo != null) { + fields.add("mobile"); + } + if (emailAddress != null) { + fields.add("email"); + } + return fields; + } + + private static List> toJsonList(final List matches) { + final List> out = new ArrayList<>(); + for (final IdentityMatch m : matches) { + final Map row = new LinkedHashMap<>(); + row.put("clientId", m.clientId()); + row.put("displayName", m.displayName()); + row.put("cifNumber", m.cifNumber()); + row.put("officeId", m.officeId()); + row.put("officeName", m.officeName()); + row.put("legalFormEnum", m.legalFormEnum()); + row.put("kycStatus", m.kycStatus()); + // True when the caller's branch can open this client; false when it sits in another branch's hierarchy. + row.put("visibleToCaller", m.visibleToCaller()); + row.put("panMasked", m.panMasked()); + row.put("aadhaarMasked", m.aadhaarMasked()); + row.put("gstinMasked", m.gstinMasked()); + row.put("matchedBy", new ArrayList<>(m.matchedBy())); + out.add(row); + } + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycContactLogApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycContactLogApiResource.java index 5f3ccae82..4876d2bb8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycContactLogApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycContactLogApiResource.java @@ -28,6 +28,7 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import java.util.List; @@ -36,6 +37,7 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSer import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.data.KycContactLogData; import org.apache.fineract.portfolio.client.service.KycContactLogService; +import org.apache.fineract.portfolio.client.service.KycNoticeComposer; import org.springframework.stereotype.Component; /** @@ -65,6 +67,7 @@ public class ClientKycContactLogApiResource { private final PlatformSecurityContext context; private final KycContactLogService kycContactLogService; + private final KycNoticeComposer noticeComposer; private final DefaultToApiJsonSerializer toApiJsonSerializer; @POST @@ -79,11 +82,31 @@ public class ClientKycContactLogApiResource { final JsonObject payload = parse(apiRequestBodyAsJson); final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"), - stringOrNull(payload, "notes"), stringOrNull(payload, "officerName")); + stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"), stringOrNull(payload, "noticeType")); return this.toApiJsonSerializer.serialize(result); } + @GET + @Path("preview") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Preview the notice a customer would receive", + description = "The subject and text for the stage the customer is at today. type is KYC or DORMANCY. " + + "Empty subject and body when the customer is at no stage of that kind.") + public String previewNotice(@PathParam("clientId") final Long clientId, @QueryParam("type") final String type) { + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final KycNoticeComposer.KycNotice notice = this.noticeComposer.compose(clientId, type); + final JsonObject json = new JsonObject(); + if (notice != null) { + final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(notice); + json.addProperty("subject", delivery.subject()); + json.addProperty("body", delivery.body()); + } + return json.toString(); + } + @GET @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java index 9c134190d..4666fb973 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java @@ -89,4 +89,24 @@ public class ClientKycExceptionsApiResource { LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size()); return this.toApiJsonSerializer.serialize(lapsed); } + + /** + * Customers whose periodic re-KYC falls due within the next {@code days} days (default 30). + * + *

+ * The counterpart of {@code lapsed}: nothing has stopped working for these customers yet, which is + * why they are worth reaching now. + */ + @GET + @Path("expiring") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve KYC records expiring soon", description = "Returns clients whose periodic re-KYC falls due within the next N days (default 30), soonest first.") + public String retrieveExpiringKyc(@QueryParam("days") final Integer days) { + this.context.authenticatedUser(); + final int window = days == null ? 30 : days; + final List expiring = this.readPlatformService.retrieveExpiringKyc(window); + LOG.info("retrieveExpiringKyc: fetched {} KYC record(s) expiring within {} day(s)", expiring.size(), window); + return this.toApiJsonSerializer.serialize(expiring); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycContactAuditApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycContactAuditApiResource.java new file mode 100644 index 000000000..84988f18e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycContactAuditApiResource.java @@ -0,0 +1,434 @@ +/** + * 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.client.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +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.sql.Timestamp; +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 lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Every customer notice on record, across all customers, for audit. + * + *

+ *   GET /v1/kyc/contact-audit/days   one line per day: how many customers and notices, and how they fared
+ *   GET /v1/kyc/contact-audit        the notices themselves, a page at a time: {"total": n, "rows": [...]}
+ * 
+ * + *

+ * Both take the same optional filters: {@code from} and {@code to} (yyyy-MM-dd, both inclusive), + * {@code channel}, {@code status} and {@code search} (customer name, account number or reference). The + * per-customer contact log answers "what was this customer told"; this answers "what did the bank send, + * to whom, and did it arrive" — the question an inspection asks. + * + *

+ * Built for a bank with a very large number of customers: the day view is computed in the database and + * is a few hundred rows at most, and the notices of one day are fetched a page at a time, so nothing + * here ever loads the whole log. + */ +@Path("/v1/kyc/contact-audit") +@Component +@Tag(name = "Client KYC Contact Audit", description = "All customer notices on record.") +@RequiredArgsConstructor +public class KycContactAuditApiResource { + + private static final int MAX_PAGE = 500; + private static final int MAX_DAYS = 400; + + private static final String FROM_CLAUSE = " FROM m_client_kyc_contact_log l JOIN m_client c ON c.id = l.client_id WHERE 1 = 1"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new Gson(); + + @GET + @Path("days") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.") + public String days(@QueryParam("from") final String from, @QueryParam("to") final String to, + @QueryParam("channel") final String channel, @QueryParam("status") final String status, + @QueryParam("search") final String search, @QueryParam("real") final Boolean real) { + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final List args = new ArrayList<>(); + final String where = filters(from, to, channel, status, search, real, args); + final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, " + + "COUNT(DISTINCT CASE WHEN l.delivery_status = 'SENT' THEN l.client_id END) AS notified, " + + "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, " + + "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, " + + "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, " + + "SUM(CASE WHEN l.delivery_status = 'PROVIDER_NOT_CONFIGURED' THEN 1 ELSE 0 END) AS not_configured, " + + "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded" + FROM_CLAUSE + where + + " GROUP BY CAST(l.contacted_at AS DATE) ORDER BY d DESC LIMIT " + MAX_DAYS; + + final List> out = new ArrayList<>(); + for (final Map r : this.jdbcTemplate.queryForList(sql, args.toArray())) { + final Map item = new LinkedHashMap<>(); + item.put("date", String.valueOf(r.get("d"))); + item.put("notices", number(r.get("notices"))); + item.put("customers", number(r.get("customers"))); + // Customers a notice actually reached that day, as against customers it was tried for. + item.put("notifiedCustomers", number(r.get("notified"))); + item.put("delivered", number(r.get("delivered"))); + item.put("failed", number(r.get("failed"))); + item.put("noContact", number(r.get("no_contact"))); + item.put("notConfigured", number(r.get("not_configured"))); + item.put("recorded", number(r.get("recorded"))); + out.add(item); + } + return this.gson.toJson(out); + } + + @GET + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.") + public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to, + @QueryParam("channel") final String channel, @QueryParam("status") final String status, + @QueryParam("search") final String search, @QueryParam("real") final Boolean real, + @QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) { + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final List args = new ArrayList<>(); + final String where = filters(from, to, channel, status, search, real, args); + + final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray()); + + final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE); + final int skip = Math.max(offset == null ? 0 : offset, 0); + final String sql = "SELECT l.id, l.client_id, c.display_name, c.account_no, l.channel, l.notes, l.officer_name, " + + "l.delivery_status, l.destination_masked, l.provider, l.provider_reference, l.failure_reason, l.message_body, " + + "l.contacted_at" + FROM_CLAUSE + where + " ORDER BY l.contacted_at DESC, l.id DESC LIMIT " + rows + " OFFSET " + skip; + + final List> list = new ArrayList<>(); + for (final Map r : this.jdbcTemplate.queryForList(sql, args.toArray())) { + final Map item = new LinkedHashMap<>(); + item.put("id", r.get("id")); + item.put("clientId", r.get("client_id")); + item.put("clientName", r.get("display_name")); + item.put("accountNo", r.get("account_no")); + item.put("channel", r.get("channel")); + item.put("notes", r.get("notes")); + item.put("officerName", r.get("officer_name")); + item.put("deliveryStatus", r.get("delivery_status")); + item.put("destinationMasked", r.get("destination_masked")); + item.put("provider", r.get("provider")); + item.put("providerReference", r.get("provider_reference")); + item.put("failureReason", r.get("failure_reason")); + item.put("messageBody", r.get("message_body")); + final Object at = r.get("contacted_at"); + item.put("contactedAt", at == null ? null : String.valueOf(at).replace(' ', 'T')); + list.add(item); + } + + final Map out = new LinkedHashMap<>(); + out.put("total", total == null ? 0 : total); + out.put("rows", list); + return this.gson.toJson(out); + } + + /** + * One line per customer: how many notices, how they fared, and the latest. Newest activity first, a page at a time. + * Answers "who was contacted, and what happened" without paging through every day. + */ + @GET + @Path("customers") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Notices per customer", description = "Same filters as the day view. Returns total and rows.") + public String customers(@QueryParam("from") final String from, @QueryParam("to") final String to, + @QueryParam("channel") final String channel, @QueryParam("status") final String status, + @QueryParam("search") final String search, @QueryParam("real") final Boolean real, + @QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) { + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final List args = new ArrayList<>(); + final String where = filters(from, to, channel, status, search, real, args); + + final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT l.client_id)" + FROM_CLAUSE + where, Long.class, + args.toArray()); + + final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE); + final int skip = Math.max(offset == null ? 0 : offset, 0); + final String sql = "SELECT l.client_id, c.display_name, c.account_no, COUNT(*) AS notices, " + + "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, " + + "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, " + + "SUM(CASE WHEN l.delivery_status IN ('SKIPPED_NO_DESTINATION', 'PROVIDER_NOT_CONFIGURED') THEN 1 ELSE 0 END) AS not_sent, " + + "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded, " + + "MAX(l.contacted_at) AS last_at" + FROM_CLAUSE + where + + " GROUP BY l.client_id, c.display_name, c.account_no ORDER BY last_at DESC, l.client_id DESC LIMIT " + rows + " OFFSET " + + skip; + + final List> list = new ArrayList<>(); + final List pageIds = new ArrayList<>(); + for (final Map r : this.jdbcTemplate.queryForList(sql, args.toArray())) { + final Map item = new LinkedHashMap<>(); + item.put("clientId", r.get("client_id")); + item.put("clientName", r.get("display_name")); + item.put("accountNo", r.get("account_no")); + item.put("notices", number(r.get("notices"))); + item.put("delivered", number(r.get("delivered"))); + item.put("failed", number(r.get("failed"))); + item.put("notSent", number(r.get("not_sent"))); + item.put("recorded", number(r.get("recorded"))); + final Object at = r.get("last_at"); + item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T')); + pageIds.add(((Number) r.get("client_id")).longValue()); + list.add(item); + } + + // The latest notice's channel and outcome, for the customers on this page only. + if (!pageIds.isEmpty()) { + final String marks = String.join(",", pageIds.stream().map(x -> "?").toList()); + final Map> byId = new LinkedHashMap<>(); + list.forEach((m) -> byId.put(((Number) m.get("clientId")).longValue(), m)); + final List latestArgs = new ArrayList<>(pageIds); + for (final Map r : this.jdbcTemplate.queryForList( + "SELECT client_id, channel, delivery_status FROM m_client_kyc_contact_log WHERE client_id IN (" + marks + + ") ORDER BY contacted_at DESC, id DESC", + latestArgs.toArray())) { + final Map item = byId.get(((Number) r.get("client_id")).longValue()); + if (item != null && !item.containsKey("lastChannel")) { + item.put("lastChannel", r.get("channel")); + item.put("lastStatus", r.get("delivery_status")); + } + } + } + + final Map out = new LinkedHashMap<>(); + out.put("total", total == null ? 0 : total); + out.put("rows", list); + return this.gson.toJson(out); + } + + /** + * What the listed customers have been sent so far: the latest notice and which channels have been done. + * One round trip for a whole screen of customers, so a list can show "last notice" without a call per row. + */ + @GET + @Path("clients") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Notice summary for some customers", description = "ids is a comma-separated list of client ids (up to 500).") + public String clients(@QueryParam("ids") final String ids) { + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final List clientIds = new ArrayList<>(); + if (StringUtils.isNotBlank(ids)) { + for (final String part : ids.split(",")) { + if (StringUtils.isNumeric(part.trim()) && clientIds.size() < MAX_PAGE) { + clientIds.add(Long.valueOf(part.trim())); + } + } + } + if (clientIds.isEmpty()) { + return this.gson.toJson(new ArrayList<>()); + } + + final String marks = String.join(",", clientIds.stream().map(x -> "?").toList()); + final List> rows = this.jdbcTemplate.queryForList( + "SELECT client_id, channel, delivery_status, contacted_at, message_body FROM m_client_kyc_contact_log WHERE client_id IN (" + marks + + ") ORDER BY contacted_at DESC, id DESC", + clientIds.toArray()); + + final Map> byClient = new LinkedHashMap<>(); + // Every customer asked for comes back, with or without notices, so the screen knows who has no mobile or email. + for (final Map c : this.jdbcTemplate.queryForList( + "SELECT c.id, c.mobile_no, c.email_address, " + + "COALESCE((SELECT SUM(s.account_balance_derived) FROM m_savings_account s WHERE s.client_id = c.id AND s.status_enum = 300), 0) " + + "+ COALESCE((SELECT SUM(a.account_balance_derived) FROM m_current_account a WHERE a.client_id = c.id AND a.status_enum = 300), 0) AS balance " + + "FROM m_client c WHERE c.id IN (" + marks + ")", clientIds.toArray())) { + final Long id = ((Number) c.get("id")).longValue(); + final Map m = new LinkedHashMap<>(); + m.put("clientId", id); + m.put("count", 0); + m.put("lastAt", null); + m.put("lastChannel", null); + m.put("lastStatus", null); + m.put("doneChannels", new ArrayList()); + m.put("doneByStage", new LinkedHashMap>()); + m.put("noticesByStage", new LinkedHashMap()); + // What the customer holds in open savings and current accounts. + m.put("balance", c.get("balance")); + m.put("hasMobile", StringUtils.isNotBlank((String) c.get("mobile_no"))); + m.put("hasEmail", StringUtils.isNotBlank((String) c.get("email_address"))); + // Masked, never the full value: the screen only needs to show that something is on file. + m.put("mobileMasked", maskMobile((String) c.get("mobile_no"))); + m.put("emailMasked", maskEmail((String) c.get("email_address"))); + byClient.put(id, m); + } + for (final Map r : rows) { + final Long clientId = ((Number) r.get("client_id")).longValue(); + final Map item = byClient.computeIfAbsent(clientId, k -> { + final Map m = new LinkedHashMap<>(); + m.put("clientId", k); + m.put("count", 0); + m.put("lastAt", null); + m.put("lastChannel", null); + m.put("lastStatus", null); + m.put("doneChannels", new ArrayList()); + m.put("doneByStage", new LinkedHashMap>()); + m.put("noticesByStage", new LinkedHashMap()); + return m; + }); + if (item.get("lastAt") == null) { + // Rows come newest first, so the first one seen is the latest notice. + final Object at = r.get("contacted_at"); + item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T')); + item.put("lastChannel", r.get("channel")); + item.put("lastStatus", r.get("delivery_status")); + } + item.put("count", ((Integer) item.get("count")) + 1); + // A channel counts as done once it reached the customer, or was recorded (a call or a visit). + final String status = String.valueOf(r.get("delivery_status")); + final String channel = String.valueOf(r.get("channel")); + @SuppressWarnings("unchecked") + final List done = (List) item.get("doneChannels"); + if (("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) && !done.contains(channel)) { + done.add(channel); + } + // The same, by the stage the notice was written for, so a notice sent at an earlier stage is not taken for this one. + // A call, a visit or any other record that is not a stage notice is filed under ANY: it counts at whatever stage the customer is at. + final String found = stageOfNotice((String) r.get("message_body")); + final String stage = found == null ? "ANY" : found; + if ("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) { + @SuppressWarnings("unchecked") + final Map> byStage = (Map>) item.get("doneByStage"); + @SuppressWarnings("unchecked") + final Map counts = (Map) item.get("noticesByStage"); + counts.merge(stage, 1, Integer::sum); + final List inStage = byStage.computeIfAbsent(stage, k -> new ArrayList<>()); + if (!inStage.contains(channel)) { + inStage.add(channel); + } + } + } + return this.gson.toJson(new ArrayList<>(byClient.values())); + } + + /** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */ + private String filters(final String from, final String to, final String channel, final String status, final String search, + final Boolean real, final List args) { + final StringBuilder where = new StringBuilder(); + if (Boolean.TRUE.equals(real)) { + // An attempt that went nowhere (no number or address on file, or no gateway set up) is not a notice a customer received. + where.append(" AND l.delivery_status IN ('SENT', 'FAILED', 'NOT_APPLICABLE')"); + } + if (StringUtils.isNotBlank(from)) { + where.append(" AND l.contacted_at >= ?"); + args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay())); + } + if (StringUtils.isNotBlank(to)) { + where.append(" AND l.contacted_at < ?"); + args.add(Timestamp.valueOf(parse(to, "to").plusDays(1).atStartOfDay())); + } + if (StringUtils.isNotBlank(channel)) { + where.append(" AND l.channel = ?"); + args.add(channel.trim().toUpperCase()); + } + if (StringUtils.isNotBlank(status)) { + where.append(" AND l.delivery_status = ?"); + args.add(status.trim().toUpperCase()); + } + if (StringUtils.isNotBlank(search)) { + final String like = "%" + search.trim().toLowerCase() + "%"; + where.append(" AND (LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(l.provider_reference) LIKE ?)"); + args.add(like); + args.add(like); + args.add(like); + } + return where.toString(); + } + + /** + * The stage a notice was written for, read from its wording (the composer words every stage differently). Null for a call, + * a visit or any message that is not one of the stage notices. + */ + private static String stageOfNotice(final String body) { + if (StringUtils.isBlank(body)) { + return null; + } + if (body.startsWith("Your KYC expires on")) { + return "EXPIRING"; + } + if (body.startsWith("Your KYC expired on")) { + if (body.contains("account is now inoperative")) { + return "INOPERATIVE"; + } + return body.contains("debits on your account are now stopped") ? "PARTIAL_FREEZE" : "GRACE"; + } + if (body.startsWith("Your account ")) { + if (body.contains("has been closed")) { + return "ESCHEAT"; + } + return body.contains("is dormant") ? "DORMANT" : body.contains("is inactive") ? "INACTIVE" : null; + } + return null; + } + + private static String maskMobile(final String mobile) { + if (StringUtils.isBlank(mobile)) { + return null; + } + final String digits = mobile.trim(); + return digits.length() <= 4 ? digits : "XXXXXX" + digits.substring(digits.length() - 4); + } + + private static String maskEmail(final String email) { + if (StringUtils.isBlank(email) || !email.contains("@")) { + return StringUtils.isBlank(email) ? null : email; + } + final String trimmed = email.trim(); + final int at = trimmed.indexOf('@'); + return trimmed.substring(0, Math.min(1, at)) + "***" + trimmed.substring(at); + } + + private static long number(final Object value) { + return value == null ? 0 : ((Number) value).longValue(); + } + + private LocalDate parse(final String value, final String parameter) { + try { + return LocalDate.parse(value.trim()); + } catch (final DateTimeParseException e) { + throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError( + "validation.msg.kyc.contact.audit." + parameter + ".invalid", parameter + " must look like 2028-02-17.", parameter))); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycNoticeQueueApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycNoticeQueueApiResource.java new file mode 100644 index 000000000..b21ebd447 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycNoticeQueueApiResource.java @@ -0,0 +1,157 @@ +/** + * 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.client.api; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.service.KycNoticeQueueService; +import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.EnqueueResult; +import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.QueueItem; +import org.springframework.stereotype.Component; + +/** + * Notices queued to be sent in the background. + * + *
+ *   POST /v1/kyc/notice-queue                  body: {"items":[{"clientId":1,"noticeType":"KYC","channel":"SMS",
+ *                                                      "accountNo":"..","stageLabel":".."}],
+ *                                                      "notes":"..","officerName":"..","sendAt":"2028-02-17T10:00"}
+ *   GET  /v1/kyc/notice-queue?status=PENDING   newest first
+ *   POST /v1/kyc/notice-queue/{id}?command=cancel | retry
+ * 
+ * + *

Authorisation matches the contact log: sending a notice to a customer is a maintenance action on + * the customer's record. + */ +@Path("/v1/kyc/notice-queue") +@Component +@Tag(name = "Client KYC Notice Queue", description = "Queue customer notices to be sent in the background.") +@RequiredArgsConstructor +public class KycNoticeQueueApiResource { + + private final PlatformSecurityContext context; + private final KycNoticeQueueService queueService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Queue notices for background sending") + public String enqueue(final String apiRequestBodyAsJson) { + this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final JsonObject payload = parse(apiRequestBodyAsJson); + final List items = new ArrayList<>(); + final JsonElement raw = payload.get("items"); + if (raw != null && raw.isJsonArray()) { + final JsonArray array = raw.getAsJsonArray(); + for (final JsonElement element : array) { + if (!element.isJsonObject()) { + continue; + } + final JsonObject o = element.getAsJsonObject(); + final String clientId = string(o, "clientId"); + items.add(new QueueItem(clientId == null ? null : Long.valueOf(clientId), string(o, "noticeType"), string(o, "channel"), + string(o, "accountNo"), string(o, "stageLabel"))); + } + } + + LocalDateTime sendAt = null; + final String sendAtText = string(payload, "sendAt"); + if (sendAtText != null && !sendAtText.isBlank()) { + try { + sendAt = LocalDateTime.parse(sendAtText); + } catch (final DateTimeParseException e) { + throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError( + "validation.msg.kyc.notice.queue.sendAt.invalid", "sendAt must look like 2028-02-17T10:00.", "sendAt"))); + } + } + + final EnqueueResult result = this.queueService.enqueue(items, string(payload, "notes"), string(payload, "officerName"), sendAt); + final JsonObject out = new JsonObject(); + out.addProperty("queued", result.queued()); + out.addProperty("skipped", result.skipped()); + out.addProperty("unreachable", result.unreachable()); + return out.toString(); + } + + @GET + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List queued notices", description = "Newest first. status narrows to PENDING, SENT, FAILED or CANCELLED.") + public String list(@QueryParam("status") final String status, @QueryParam("limit") final Integer limit) { + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + return this.gson.toJson(this.queueService.list(status, limit == null ? 200 : limit)); + } + + @POST + @Path("{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Cancel or retry a queued notice", description = "command=cancel withdraws a notice still waiting; " + + "command=retry puts a failed one back in line.") + public String act(@PathParam("id") final Long id, @QueryParam("command") final String command) { + this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + if ("cancel".equalsIgnoreCase(command)) { + this.queueService.cancel(id); + } else if ("retry".equalsIgnoreCase(command)) { + this.queueService.retry(id); + } else { + throw new PlatformApiDataValidationException(List.of(ApiParameterError + .parameterError("validation.msg.kyc.notice.queue.command.invalid", "command must be cancel or retry.", "command"))); + } + final JsonObject out = new JsonObject(); + out.addProperty("id", id); + return out.toString(); + } + + private JsonObject parse(final String json) { + if (json == null || json.isBlank()) { + return new JsonObject(); + } + final JsonElement parsed = JsonParser.parseString(json); + return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + } + + private String string(final JsonObject o, final String member) { + final JsonElement element = o.get(member); + return element == null || element.isJsonNull() ? null : element.getAsString(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java index ac31daa26..91206806a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java @@ -33,6 +33,8 @@ public final class ClientKycApiConstants { public static final String riskCategoryParam = "riskCategory"; public static final String panNumberParam = "panNumber"; public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked"; + /** SHA-256 of the full Aadhaar, computed client-side. The 12-digit number itself is never sent on create/update. */ + public static final String aadhaarHashParam = "aadhaarHash"; public static final String gstinNumberParam = "gstinNumber"; public static final String remarksParam = "remarks"; public static final String ckycNumberParam = "ckycNumber"; @@ -118,7 +120,7 @@ public final class ClientKycApiConstants { // ── Supported parameters for initiate/update ────────────────────────────── public static final Set INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( - kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, + kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam, gstinNumberParam, remarksParam, "locale", "dateFormat", panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, @@ -136,7 +138,7 @@ public final class ClientKycApiConstants { declaredAnnualIncomeParam, declaredAnnualTurnoverParam)); public static final Set UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( - kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, + kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam, gstinNumberParam, riskCategoryParam, remarksParam, "locale", "dateFormat", panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java index 4150ec7cf..3e3883dad 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java @@ -64,6 +64,8 @@ public final class KycRiskRuleData implements Serializable { * used in matching; a null maximum means the rule is genuinely open-ended. */ private final BigDecimal txnMinLimitAmount; private final BigDecimal txnMaxLimitAmount; + /** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */ + private final boolean ccRelevant; public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId, final Long occupationCategoryCvId, final String occupationCategoryName, @@ -76,7 +78,7 @@ public final class KycRiskRuleData implements Serializable { final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag, final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays, final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk, - final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) { + final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant) { this.id = id; this.ruleName = ruleName; this.ruleKind = ruleKind; @@ -106,6 +108,7 @@ public final class KycRiskRuleData implements Serializable { this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk; this.txnMinLimitAmount = txnMinLimitAmount; this.txnMaxLimitAmount = txnMaxLimitAmount; + this.ccRelevant = ccRelevant; } public Long getId() { @@ -223,4 +226,8 @@ public final class KycRiskRuleData implements Serializable { public BigDecimal getTxnMaxLimitAmount() { return txnMaxLimitAmount; } + + public boolean isCcRelevant() { + return ccRelevant; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepositoryWrapper.java index ba8c23953..c6a0e023a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepositoryWrapper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepositoryWrapper.java @@ -25,6 +25,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; import org.apache.fineract.portfolio.client.exception.ClientNotFoundException; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +40,7 @@ public class ClientRepositoryWrapper { private final ClientRepository repository; private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; @Transactional(readOnly = true) public Client findOneWithNotFoundDetection(final Long id) { @@ -94,12 +96,33 @@ public class ClientRepositoryWrapper { public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) { Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, hierarchySearchString); + // A customer of another branch who has been linked to the caller's branch can be seen here, so the branch can + // open an account for them. Reading only: the write paths do not go through this method. + if (client == null && isLinkedToBranchUnder(clientId, hierarchySearchString)) { + client = this.repository.findById(clientId).orElse(null); + } if (client == null) { throw new ClientNotFoundException(clientId.toString(), "client.id"); } return client; } + /** + * True when the client has been linked to an office at or below the given hierarchy (a LIKE pattern ending in %), or + * holds an account there. A link is what lets a branch open the customer's page to start an account; the client list + * is stricter and shows the customer only once an account exists. + */ + public boolean isLinkedToBranchUnder(final Long clientId, final String hierarchySearchString) { + final Integer count = this.jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM (" + + "SELECT l.client_id FROM m_client_branch_link l JOIN m_office lo ON lo.id = l.office_id WHERE l.client_id = ? AND lo.hierarchy LIKE ? " + + "UNION ALL SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id WHERE sa.client_id = ? AND ao.hierarchy LIKE ? " + + "UNION ALL SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id WHERE ca.client_id = ? AND co.hierarchy LIKE ?" + + ") t", + Integer.class, clientId, hierarchySearchString, clientId, hierarchySearchString, clientId, hierarchySearchString); + return count != null && count > 0; + } + public Long findIdByExternalId(final ExternalId externalId) { return this.repository.findIdByExternalId(externalId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueConfig.java new file mode 100644 index 000000000..d7b2fb51e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueConfig.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.jobs.processkycnoticequeue; + +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 ProcessKycNoticeQueueConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step processKycNoticeQueueStep(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) { + return new StepBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository) + .tasklet(processKycNoticeQueueTasklet, transactionManager).build(); + } + + @Bean + public Job processKycNoticeQueueJob(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) { + return new JobBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository) + .start(processKycNoticeQueueStep(processKycNoticeQueueTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueTasklet.java new file mode 100644 index 000000000..4ae358742 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/processkycnoticequeue/ProcessKycNoticeQueueTasklet.java @@ -0,0 +1,41 @@ +/** + * 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.client.jobs.processkycnoticequeue; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.service.KycNoticeQueueService; +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; + +/** Sends the notices officers have queued, once they are due. */ +@RequiredArgsConstructor +@Component +public class ProcessKycNoticeQueueTasklet implements Tasklet { + + private final KycNoticeQueueService kycNoticeQueueService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + kycNoticeQueueService.processDue(); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersConfig.java new file mode 100644 index 000000000..171658f4e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersConfig.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.jobs.sendkycreminders; + +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 SendKycRemindersConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step sendKycRemindersStep(SendKycRemindersTasklet sendKycRemindersTasklet) { + return new StepBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).tasklet(sendKycRemindersTasklet, transactionManager) + .build(); + } + + @Bean + public Job sendKycRemindersJob(SendKycRemindersTasklet sendKycRemindersTasklet) { + return new JobBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).start(sendKycRemindersStep(sendKycRemindersTasklet)) + .incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersTasklet.java new file mode 100644 index 000000000..5c1e8553e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/sendkycreminders/SendKycRemindersTasklet.java @@ -0,0 +1,44 @@ +/** + * 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.client.jobs.sendkycreminders; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.service.KycReminderService; +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; + +/** + * Daily pass that tells customers which stage their KYC or account has reached and what happens + * next, by SMS and email. + */ +@RequiredArgsConstructor +@Component +public class SendKycRemindersTasklet implements Tasklet { + + private final KycReminderService kycReminderService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + kycReminderService.sendReminders(); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java index 34b0f1c01..4f062df66 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java @@ -29,6 +29,7 @@ import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; import java.util.Locale; import java.util.Set; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; @@ -275,7 +276,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily // CBS extended fields clientFamilyMembers.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber")); - clientFamilyMembers.setPanNumber(command.stringValueOfParameterNamed("panNumber")); + clientFamilyMembers.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber"))); clientFamilyMembers.setNationality(command.stringValueOfParameterNamed("nationality")); final Boolean isAlive = command.booleanObjectValueOfParameterNamed("isAlive"); clientFamilyMembers.setIsAlive(isAlive != null ? isAlive : true); @@ -426,7 +427,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily familyMember.setAadhaarNumber(member.get("aadhaarNumber").getAsString()); } if (member.get("panNumber") != null && !member.get("panNumber").isJsonNull()) { - familyMember.setPanNumber(member.get("panNumber").getAsString()); + familyMember.setPanNumber(StringUtils.upperCase(member.get("panNumber").getAsString())); } if (member.get("nationality") != null && !member.get("nationality").isJsonNull()) { familyMember.setNationality(member.get("nationality").getAsString()); @@ -585,7 +586,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily clientFamilyMember.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber")); } if (command.parameterExists("panNumber")) { - clientFamilyMember.setPanNumber(command.stringValueOfParameterNamed("panNumber")); + clientFamilyMember.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber"))); } if (command.parameterExists("nationality")) { clientFamilyMember.setNationality(command.stringValueOfParameterNamed("nationality")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientIdentityService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientIdentityService.java new file mode 100644 index 000000000..f56464b30 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientIdentityService.java @@ -0,0 +1,207 @@ +/** + * 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.client.service; + +import java.util.ArrayList; +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.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.domain.AadhaarHash; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Answers "is this person already a customer of the bank?" by identity number, across every branch. + * + *

+ * PAN, the Aadhaar fingerprint and GSTIN identify a person or an entity, so any ONE of them matching means the same + * customer. Phone and email are only contact details - a match there is a different customer using a number or + * address that is already taken, never the same customer. + * + *

+ * The lookup deliberately ignores the caller's office hierarchy: the whole point is that a branch must learn that the + * customer is already on the books at another branch. What it hands back is kept to the minimum needed to act on that + * (name, CIF, branch, masked identifiers) and every lookup is logged. + */ +@Service +@RequiredArgsConstructor +public class ClientIdentityService { + + /** What a caller is told about an existing customer. Identifiers are masked; nothing here is editable. */ + public record IdentityMatch(Long clientId, String displayName, String cifNumber, Long officeId, String officeName, + Integer legalFormEnum, Integer kycStatus, boolean visibleToCaller, String panMasked, String aadhaarMasked, + String gstinMasked, Set matchedBy) { + } + + public static final String MATCHED_PAN = "PAN"; + public static final String MATCHED_AADHAAR = "AADHAAR"; + public static final String MATCHED_GSTIN = "GSTIN"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + /** + * Every active client whose KYC carries any of the given identifiers. A blank identifier is ignored; if all three + * are blank nothing can match. At most one row per client (a client has one KYC row). + * + * @param excludeClientId a client to leave out (the one being edited), or null + */ + public List findMatches(final String panNumber, final String aadhaarHash, final String gstinNumber, + final Long excludeClientId) { + final String pan = normalizeId(panNumber); + final String hash = AadhaarHash.normalizeOrNull(aadhaarHash); + final String gstin = normalizeId(gstinNumber); + + final List conditions = new ArrayList<>(); + final List params = new ArrayList<>(); + if (pan != null) { + conditions.add("k.pan_number = ?"); + params.add(pan); + } + if (hash != null) { + conditions.add("k.aadhaar_hash = ?"); + params.add(hash); + } + if (gstin != null) { + conditions.add("k.gstin_number = ?"); + params.add(gstin); + } + if (conditions.isEmpty()) { + return List.of(); + } + + final StringBuilder sql = new StringBuilder("SELECT c.id, c.display_name, c.cif_number, c.office_id, o.name AS office_name, ") + .append("o.hierarchy AS office_hierarchy, c.legal_form_enum, k.kyc_status, k.pan_number, ") + .append("k.aadhaar_number_masked, k.aadhaar_hash, k.gstin_number ") + .append("FROM m_client_kyc k JOIN m_client c ON c.id = k.client_id JOIN m_office o ON o.id = c.office_id ") + .append("WHERE k.is_deleted = false AND (").append(String.join(" OR ", conditions)).append(")"); + if (excludeClientId != null) { + sql.append(" AND c.id <> ?"); + params.add(excludeClientId); + } + sql.append(" ORDER BY c.id"); + + final String callerHierarchy = this.context.officeHierarchy(); + return this.jdbcTemplate.query(sql.toString(), (rs, rowNum) -> { + final Set matchedBy = new LinkedHashSet<>(); + if (pan != null && pan.equals(rs.getString("pan_number"))) { + matchedBy.add(MATCHED_PAN); + } + if (hash != null && hash.equals(rs.getString("aadhaar_hash"))) { + matchedBy.add(MATCHED_AADHAAR); + } + if (gstin != null && gstin.equals(rs.getString("gstin_number"))) { + matchedBy.add(MATCHED_GSTIN); + } + final String officeHierarchy = rs.getString("office_hierarchy"); + // wasNull() only describes the column read immediately before it, so capture it right here. + final int legalForm = rs.getInt("legal_form_enum"); + final boolean legalFormMissing = rs.wasNull(); + return new IdentityMatch(rs.getLong("id"), rs.getString("display_name"), rs.getString("cif_number"), + rs.getLong("office_id"), rs.getString("office_name"), legalFormMissing ? null : legalForm, + rs.getInt("kyc_status"), officeHierarchy != null && officeHierarchy.startsWith(callerHierarchy), + maskKeepingEnds(rs.getString("pan_number"), 2, 1), rs.getString("aadhaar_number_masked"), + maskKeepingEnds(rs.getString("gstin_number"), 2, 3), matchedBy); + }, params.toArray()); + } + + /** + * Whether a mobile number or email address is already used by another client. Contact details are unique per + * client, and a hit here is never "the same customer". + */ + public Map contactConflicts(final String mobileNo, final String emailAddress, final Long excludeClientId) { + final Map conflicts = new LinkedHashMap<>(); + conflicts.put("mobileNo", isTaken("mobile_no", StringUtils.trimToNull(mobileNo), excludeClientId)); + conflicts.put("emailAddress", isTaken("email_address", StringUtils.trimToNull(emailAddress), excludeClientId)); + return conflicts; + } + + /** + * Rejects a KYC save that would put an identifier on a second client. Called with only the identifiers being + * added or changed, so editing a client that already shares an identifier with another (possible in data saved + * before this check existed) is not blocked by that old clash. + */ + public void assertNoDuplicate(final Long clientId, final String panNumber, final String aadhaarHash, final String gstinNumber) { + final List matches = findMatches(panNumber, aadhaarHash, gstinNumber, clientId); + if (matches.isEmpty()) { + return; + } + final IdentityMatch other = matches.get(0); + final String where = " (CIF " + other.cifNumber() + ", branch " + other.officeName() + ")."; + if (other.matchedBy().contains(MATCHED_PAN)) { + throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.panNumber", + "A client with PAN `" + normalizeId(panNumber) + "` already exists" + where + + " PAN must be unique across clients.", "panNumber", normalizeId(panNumber)); + } + if (other.matchedBy().contains(MATCHED_AADHAAR)) { + throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.aadhaar", + "A client with this Aadhaar already exists" + where + " Aadhaar must be unique across clients.", + "aadhaarNumber", other.cifNumber()); + } + throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.gstin", + "A client with GSTIN `" + normalizeId(gstinNumber) + "` already exists" + where + + " GSTIN must be unique across clients.", "gstinNumber", normalizeId(gstinNumber)); + } + + /** + * Checks an Aadhaar fingerprint from a request is well formed, as a normal validation error rather than a 500. + * Blank is allowed (no Aadhaar). + */ + public static void validateAadhaarHashOrThrow(final String rawHash) { + if (StringUtils.isNotBlank(rawHash) && !AadhaarHash.isValid(rawHash.trim().toLowerCase(Locale.ROOT))) { + throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError( + "error.msg.client.kyc.aadhaarHash.invalid", "aadhaarHash must be a 64-character hex SHA-256 digest.", + "aadhaarHash"))); + } + } + + /** PAN and GSTIN are stored upper-case; compare them that way. Null for blank. */ + public static String normalizeId(final String raw) { + final String trimmed = StringUtils.trimToNull(raw); + return trimmed == null ? null : trimmed.toUpperCase(Locale.ROOT); + } + + private boolean isTaken(final String column, final String value, final Long excludeClientId) { + if (value == null) { + return false; + } + // column is one of two fixed names above, never caller input. + final String sql = "SELECT COUNT(1) FROM m_client WHERE " + column + " = ?" + (excludeClientId != null ? " AND id <> ?" : ""); + final Integer count = excludeClientId != null ? this.jdbcTemplate.queryForObject(sql, Integer.class, value, excludeClientId) + : this.jdbcTemplate.queryForObject(sql, Integer.class, value); + return count != null && count > 0; + } + + /** Keeps the first {@code head} and last {@code tail} characters, e.g. PAN ABCDE1234F -> AB*******F. */ + private static String maskKeepingEnds(final String value, final int head, final int tail) { + if (value == null || value.length() <= head + tail) { + return value == null ? null : "*".repeat(value.length()); + } + return value.substring(0, head) + "*".repeat(value.length() - head - tail) + value.substring(value.length() - tail); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java index 664ebb443..02bcd1dd4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java @@ -45,4 +45,14 @@ public interface ClientKycReadPlatformService { * included: nothing is owed on it yet. */ java.util.List retrieveLapsedKyc(); + + /** + * KYC records whose re-KYC falls due within the next {@code days} days, soonest first. + * + *

+ * Nothing is owed on these yet, but they are the customers the branch can still reach before the + * restriction ladder starts. A record that has already lapsed is in {@link #retrieveLapsedKyc()} + * instead. + */ + java.util.List retrieveExpiringKyc(int days); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java index 1e8638804..b23f6376b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -173,4 +173,13 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .map(this::toData) .collect(java.util.stream.Collectors.toList()); } + + @Override + public java.util.List retrieveExpiringKyc(final int days) { + final java.time.LocalDate today = DateUtils.getBusinessLocalDate(); + return this.kycRepository + .findExpiringBetween(today, today.plusDays(Math.max(days, 0)), ClientKycStatus.REJECTED.getValue()).stream() + .map(this::toData) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java index 957f003d6..97ab1f55c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -77,6 +77,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService; private final SavingsAccountRepository savingsAccountRepository; private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService; + private final ClientIdentityService clientIdentityService; /** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */ private static final int CKYC_ZIP_MAX_BYTES = 50 * 1024 * 1024; @@ -85,7 +86,8 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc @Override public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) { this.context.authenticatedUser(); - LOG.info("initiateKyc: clientId={}, requestJson={}", clientId, command.json()); + LOG.info("initiateKyc: clientId={}, requestJson={}", clientId, + command.json().replaceAll("\"aadhaarHash\"\\s*:\\s*\"[0-9a-fA-F]*\"", "\"aadhaarHash\":\"\"")); final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId); @@ -108,6 +110,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc .build(); } + // PAN, Aadhaar and GSTIN identify one customer for the whole bank. The create-client request used to be the + // only place this was checked, but the wizard saves the KYC through this call, so a duplicate got through. + final String aadhaarHashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam); + ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHashInRequest); + this.clientIdentityService.assertNoDuplicate(clientId, command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam), + aadhaarHashInRequest, command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam)); + try { final Client client = clientForValidation; final ClientKyc kyc = ClientKyc.initiateKyc(client, command); @@ -145,6 +154,18 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc final ClientKycStatus currentStatus = kyc.getKycStatus(); + // Check only identifiers this request adds or changes. A client that already shares one with another client + // (saved before this check existed) must still be editable. + final String hashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam); + ClientIdentityService.validateAadhaarHashOrThrow(hashInRequest); + final String newPan = command.isChangeInStringParameterNamed(ClientKycApiConstants.panNumberParam, kyc.getPanNumber()) + ? command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam) : null; + final String newGstin = command.isChangeInStringParameterNamed(ClientKycApiConstants.gstinNumberParam, kyc.getGstinNumber()) + ? command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam) : null; + final String newHash = StringUtils.isNotBlank(hashInRequest) && !hashInRequest.trim().equalsIgnoreCase(kyc.getAadhaarHash()) + ? hashInRequest : null; + this.clientIdentityService.assertNoDuplicate(clientId, newPan, newHash, newGstin); + final Map changes = kyc.update(command); // When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java index f28a19106..f0aec24f1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java @@ -109,11 +109,18 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService // this.context.validateAccessRights(searchParameters.getHierarchy()); // underHierarchySearchString = searchParameters.getHierarchy() + "%"; // } - List paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString)); + List paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString, + underHierarchySearchString, underHierarchySearchString)); final StringBuilder sqlBuilder = new StringBuilder(200); sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); sqlBuilder.append(this.clientToDataMapper.schema()); - sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?) "); + // The last two alternatives: a customer of another branch who holds an account at this branch. Being linked to a + // branch is not enough to be listed there; the customer appears once an account is actually opened. + sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?" + + " or c.id in (select sa.client_id from m_savings_account sa join m_office ao on ao.id = sa.office_id" + + " where sa.client_id is not null and ao.hierarchy like ?)" + + " or c.id in (select ca.client_id from m_current_account ca join m_office co on co.id = ca.office_id" + + " where ca.client_id is not null and co.hierarchy like ?)) "); if (searchParameters != null) { if (searchParameters.getIsSelfUser()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index bfc03c613..f3a829f90 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -274,7 +274,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP aadhaar = this.fromApiJsonHelper.extractStringNamed( org.apache.fineract.portfolio.client.data.ClientKycApiConstants.aadhaarNumberParam, kyc); } - if (StringUtils.isNotBlank(aadhaar)) { + // "XXXXXXXX0000" is the placeholder the wizard sends for entities and for clients with no Aadhaar. It is not a + // number, so every such client would "match" every other one. + if (StringUtils.isNotBlank(aadhaar) && !"XXXXXXXX0000".equals(aadhaar)) { final String masked = aadhaar.length() == 12 ? "XXXXXXXX" + aadhaar.substring(8) : aadhaar; final boolean clash = excludeClientId == null ? clientKycRepository.existsByAadhaarMasked(masked) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogService.java index 11fe565e2..6133d8035 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogService.java @@ -42,6 +42,37 @@ public interface KycContactLogService { */ KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName); + /** + * As {@link #logContact(Long, String, String, String)}, but a sending channel carries the notice for the stage + * the customer is at today ({@code noticeType} {@code KYC} or {@code DORMANCY}) instead of the generic + * "KYC pending" text. The stage is read from the customer's own record, not from the caller. + * + * @param noticeType {@code KYC}, {@code DORMANCY}, or blank for the generic notice + */ + KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName, String noticeType); + + /** + * Sends a notice whose wording the caller supplies, and logs it exactly as {@link #logContact} would. + * + *

Used by the nightly reminder job, which knows which stage the customer is at and what happens + * next, and says so in the message — the generic "KYC pending" text does not. Only the sending + * channels (SMS, Email, WhatsApp) are accepted. The bank's name is appended to {@code body}. + * + * @param notes recorded against the log; the caller starts it with a marker so a reminder is not + * repeated + * @param subject email subject (without the bank name) + * @param body plain-text message + */ + KycContactLogData sendNotice(Long clientId, String rawChannel, String notes, String officerName, String subject, String body); + + /** + * Sends the notice for the stage the customer is at today and logs it, without needing a logged-in user — + * for the queue job, which sends what an officer queued earlier. + * + * @param noticeType {@code KYC} or {@code DORMANCY} + */ + KycContactLogData sendStageNotice(Long clientId, String rawChannel, String notes, String officerName, String noticeType); + /** All contact attempts for one customer, oldest first. */ List retrieveForClient(Long clientId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogServiceImpl.java index 37cdfac7b..0c3f95481 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogServiceImpl.java @@ -42,6 +42,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -64,21 +65,30 @@ public class KycContactLogServiceImpl implements KycContactLogService { private final CustomerNotificationDispatcher dispatcher; private final EmailTemplateService emailTemplateService; private final PlatformSecurityContext context; + private final KycNoticeComposer noticeComposer; public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository, final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher, - final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) { + final EmailTemplateService emailTemplateService, final PlatformSecurityContext context, + final KycNoticeComposer noticeComposer) { this.clientRepositoryWrapper = clientRepositoryWrapper; this.kycRepository = kycRepository; this.contactLogRepository = contactLogRepository; this.dispatcher = dispatcher; this.emailTemplateService = emailTemplateService; this.context = context; + this.noticeComposer = noticeComposer; + } + + @Override + public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) { + return logContact(clientId, rawChannel, notes, officerName, null); } @Override @Transactional - public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) { + public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName, + final String noticeType) { final KycContactChannel channel = KycContactChannel.fromString(rawChannel); if (channel == null) { throw validationError("channel", "Unrecognised contact channel: " + rawChannel); @@ -99,11 +109,22 @@ public class KycContactLogServiceImpl implements KycContactLogService { currentUser != null ? currentUser.getId() : null, exceptionReason, now); if (channel.isPlatformSent()) { - final String subject = "Action Required: KYC Verification Pending - " + this.bankName; - final String plainBody = buildPlainBody(client, exceptionReason, channel); + String subject = "Action Required: KYC Verification Pending - " + this.bankName; + String plainBody = buildPlainBody(client, exceptionReason, channel); + String htmlTitle = "KYC Verification Pending"; + if (StringUtils.isNotBlank(noticeType)) { + final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType); + if (stage == null) { + throw validationError("noticeType", "This customer is not at a stage that has a notice: " + noticeType); + } + final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(stage); + subject = delivery.subject(); + plainBody = delivery.body(); + htmlTitle = stage.subject(); + } // Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim. final String htmlBody = channel == KycContactChannel.EMAIL - ? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody) + ? this.emailTemplateService.renderAlert(displayName(client), htmlTitle, plainBody) : null; final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody); @@ -122,6 +143,63 @@ public class KycContactLogServiceImpl implements KycContactLogService { return toData(saved); } + /** + * Its own transaction, so a notice that cannot be sent rolls back only itself. Joined to the queue job's + * transaction instead, one failure would mark the whole run rollback-only and every queued notice would + * stay waiting for ever. + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public KycContactLogData sendStageNotice(final Long clientId, final String rawChannel, final String notes, final String officerName, + final String noticeType) { + final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType); + if (stage == null) { + throw validationError("noticeType", "The customer is no longer at a stage that has a notice: " + noticeType); + } + return sendNotice(clientId, rawChannel, notes, officerName, stage.subject(), stage.body()); + } + + @Override + @Transactional + public KycContactLogData sendNotice(final Long clientId, final String rawChannel, final String notes, final String officerName, + final String subject, final String body) { + final KycContactChannel channel = KycContactChannel.fromString(rawChannel); + if (channel == null || !channel.isPlatformSent()) { + throw validationError("channel", "Only SMS, Email and WhatsApp can be sent: " + rawChannel); + } + if (StringUtils.isBlank(notes) || StringUtils.isBlank(body)) { + throw validationError("notes", "A note and a message are required."); + } + + final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null); + final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null; + + // A scheduled job has no logged-in user, so the user is optional here rather than required. + final AppUser currentUser = this.context.getAuthenticatedUserIfPresent(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + + final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel, + StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName, + currentUser != null ? currentUser.getId() : null, exceptionReason, now); + + final String fullSubject = subject + " - " + this.bankName; + final String plainBody = body.trim() + " - " + this.bankName; + final String htmlBody = channel == KycContactChannel.EMAIL + ? this.emailTemplateService.renderAlert(displayName(client), subject, plainBody) + : null; + + final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, fullSubject, plainBody, htmlBody); + log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(), + result.failureReason(), plainBody); + if (!result.isDelivered()) { + LOG.warn("Automatic notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel, result.status(), + result.failureReason()); + } + + return toData(this.contactLogRepository.saveAndFlush(log)); + } + @Override @Transactional(readOnly = true) public List retrieveForClient(final Long clientId) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeComposer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeComposer.java new file mode 100644 index 000000000..e21494ed8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeComposer.java @@ -0,0 +1,194 @@ +/** + * 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.client.service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Locale; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.client.domain.ClientKycRepository; +import org.apache.fineract.portfolio.savings.data.DormantAccountData; +import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * The one place that decides what a customer is told, for each stage they can be at. + * + *

+ * Kept apart from both the nightly reminder job and the officer's Notify button so the two can never + * word the same stage differently — and apart from the contact-log service so neither of them depends + * on the other. + * + *

+ * The stage is worked out from the customer's own record on the day the notice is composed, not taken + * from the caller. A screen that is a few minutes stale therefore cannot send a notice for a stage the + * customer has already left. + */ +@Component +public class KycNoticeComposer { + + /** The two families of notice: a re-KYC notice, or one about an account that has gone quiet. */ + public static final String TYPE_KYC = "KYC"; + public static final String TYPE_DORMANCY = "DORMANCY"; + + public static final int DEBIT_FREEZE_DAYS = 90; + public static final int INOPERATIVE_DAYS = 180; + + public static final int SUB_STATUS_INACTIVE = 100; + public static final int SUB_STATUS_DORMANT = 200; + public static final int SUB_STATUS_ESCHEAT = 300; + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH); + + /** Subject and plain-text body of one notice. */ + public record KycNotice(String subject, String body) { + } + + private final ClientKycRepository kycRepository; + private final DormantAccountReadPlatformService dormantAccountReadPlatformService; + + @Value("${fineract.email.from-name:CBSnexora}") + private String bankName; + + public KycNoticeComposer(final ClientKycRepository kycRepository, + final DormantAccountReadPlatformService dormantAccountReadPlatformService) { + this.kycRepository = kycRepository; + this.dormantAccountReadPlatformService = dormantAccountReadPlatformService; + } + + /** + * The notice for the stage this customer is at today, or {@code null} when they are at none of the + * stages of that family (no KYC expiry on record, or no inactive / dormant / escheated account). + */ + public KycNotice compose(final Long clientId, final String type) { + if (TYPE_KYC.equalsIgnoreCase(type)) { + final ClientKyc kyc = kycRepository.findByClientId(clientId).orElse(null); + if (kyc == null || kyc.getExpiryDate() == null) { + return null; + } + final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), DateUtils.getBusinessLocalDate()); + return new KycNotice(kycSubject(offset), kycMessage(kyc.getExpiryDate(), offset)); + } + if (TYPE_DORMANCY.equalsIgnoreCase(type)) { + DormantAccountData worst = null; + for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) { + if (clientId.equals(account.getClientId()) && account.getSubStatus() != null + && (worst == null || idle(account) > idle(worst))) { + worst = account; + } + } + return worst == null ? null + : new KycNotice(dormantSubject(worst), dormantMessage(worst, DateUtils.getBusinessLocalDate())); + } + return null; + } + + /** The notice as it reaches the customer: the bank's name added to the subject and the sign-off. */ + public KycNotice forDelivery(final KycNotice notice) { + return new KycNotice(notice.subject() + " - " + bankName, notice.body() + " - " + bankName); + } + + // ── Re-KYC ───────────────────────────────────────────────────────────────── + + public static String kycSubject(final long offset) { + if (offset < 0) { + return "Your KYC is due for renewal"; + } + if (offset < DEBIT_FREEZE_DAYS) { + return "Your KYC has expired"; + } + if (offset < INOPERATIVE_DAYS) { + return "Debits on your account are stopped"; + } + return "Your account is inoperative"; + } + + /** What the customer is told, by the stage they are at: where they stand and what happens next, and in how many days. */ + public static String kycMessage(final LocalDate expiry, final long offset) { + final String expired = expiry.format(DATE); + final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE); + final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE); + final String freezeIn = days(DEBIT_FREEZE_DAYS - offset); + final String inoperativeIn = days(INOPERATIVE_DAYS - offset); + if (offset < 0) { + return "Your KYC expires on " + expired + " (in " + days(-offset) + "). Please visit your branch with valid ID and address" + + " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze + " (in " + freezeIn + + ") and the account will become inoperative on " + inoperative + " (in " + inoperativeIn + ")."; + } + if (offset < DEBIT_FREEZE_DAYS) { + return "Your KYC expired on " + expired + ". Please renew it at your branch now. If it is not renewed, debits on your" + + " account will stop on " + freeze + " (in " + freezeIn + ") and the account will become inoperative on " + + inoperative + " (in " + inoperativeIn + ")."; + } + if (offset < INOPERATIVE_DAYS) { + return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your" + + " branch; if it is not renewed, the account will become inoperative on " + inoperative + " (in " + inoperativeIn + + ")."; + } + return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid" + + " documents to renew your KYC and have the account restored."; + } + + private static String days(final long n) { + return n == 1 ? "1 day" : n + " days"; + } + + // ── Inactive / dormant / escheated accounts ──────────────────────────────── + + public static String dormantSubject(final DormantAccountData account) { + final Integer sub = account.getSubStatus(); + if (sub != null && sub == SUB_STATUS_ESCHEAT) { + return "Your account has been closed"; + } + return sub != null && sub == SUB_STATUS_DORMANT ? "Your account is dormant" : "Your account is inactive"; + } + + public static String dormantMessage(final DormantAccountData account, final LocalDate today) { + final String since = account.getLastCustomerTransactionDate() != null ? account.getLastCustomerTransactionDate().format(DATE) + : "a long time"; + final Integer sub = account.getSubStatus(); + if (sub != null && sub == SUB_STATUS_ESCHEAT) { + return "Your account " + account.getAccountNo() + " has been closed after a very long period with no transaction by you" + + " (last on " + since + "), and the balance has been transferred to RBI's Depositor Education and Awareness Fund." + + " You can still claim it: please visit your branch with valid ID proof."; + } + if (sub != null && sub == SUB_STATUS_DORMANT) { + final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0 + ? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on " + + today.plusDays(account.getDaysToEscheat()).format(DATE) + " (in " + days(account.getDaysToEscheat()) + ")." + : ""; + return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since + + ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your" + + " identity." + escheat; + } + final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0 + ? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE) + + " (in " + days(account.getDaysToDormancy()) + ") and debits will be stopped." + : ""; + return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since + + ". Please make a transaction or visit your branch." + dormant; + } + + private static long idle(final DormantAccountData account) { + return account.getDaysIdle() == null ? 0 : account.getDaysIdle(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueService.java new file mode 100644 index 000000000..20824927f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueService.java @@ -0,0 +1,68 @@ +/** + * 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.client.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * Notices an officer has asked to be sent in the background. + * + *

+ * The officer picks customers and submits; the notices wait in the queue until the queue job sends + * them. Each is sent exactly as an immediate Notify would be — the wording is for the stage the + * customer is at when the notice goes out, and it is recorded in the customer's contact log. + */ +public interface KycNoticeQueueService { + + /** One customer and channel to notify. {@code noticeType} is KYC or DORMANCY. */ + record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) { + } + + /** + * How many were queued, how many were left out because the same notice was already waiting, and how many because the + * customer has no mobile number or email address to send it to. + */ + record EnqueueResult(int queued, int skipped, int unreachable) { + } + + /** + * Queues one notice per item. + * + * @param sendAt when to send; {@code null} means as soon as the queue job next runs + */ + EnqueueResult enqueue(List items, String notes, String officerName, LocalDateTime sendAt); + + /** Queue rows, newest first. {@code status} narrows to one of PENDING, SENT, FAILED, CANCELLED. */ + List> list(String status, int limit); + + /** Withdraws a notice that has not been sent yet. */ + void cancel(Long id); + + /** Puts a failed notice back to be sent again. */ + void retry(Long id); + + /** + * Sends everything that is due. Called by the queue job. + * + * @return how many notices were sent + */ + int processDue(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueServiceImpl.java new file mode 100644 index 000000000..119ec7e9f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycNoticeQueueServiceImpl.java @@ -0,0 +1,249 @@ +/** + * 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.client.service; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.data.KycContactLogData; +import org.apache.fineract.portfolio.client.domain.KycContactChannel; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KycNoticeQueueServiceImpl implements KycNoticeQueueService { + + private static final String PENDING = "PENDING"; + private static final String SENDING = "SENDING"; + private static final String SENT = "SENT"; + private static final String FAILED = "FAILED"; + private static final String CANCELLED = "CANCELLED"; + + /** A gateway that is down is tried again a couple of times, ten minutes apart, then given up on. */ + private static final int MAX_ATTEMPTS = 3; + private static final int RETRY_MINUTES = 10; + /** A notice stuck mid-send (the server stopped) is put back after this long. */ + private static final int STUCK_MINUTES = 15; + private static final int BATCH = 100; + private static final int MAX_ITEMS = 200; + + private final JdbcTemplate jdbcTemplate; + private final KycContactLogService contactLogService; + private final KycNoticeComposer noticeComposer; + private final PlatformSecurityContext context; + + @Override + public EnqueueResult enqueue(final List items, final String notes, final String officerName, final LocalDateTime sendAt) { + if (items == null || items.isEmpty()) { + throw validationError("items", "Choose at least one customer."); + } + if (items.size() > MAX_ITEMS) { + throw validationError("items", "Queue at most " + MAX_ITEMS + " notices at a time."); + } + final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant(); + final LocalDateTime when = sendAt == null || sendAt.isBefore(now) ? now : sendAt; + final String note = StringUtils.isBlank(notes) ? "Notice queued by an officer." : StringUtils.abbreviate(notes.trim(), 1000); + + final AppUser user = this.context.getAuthenticatedUserIfPresent(); + final Long userId = user != null ? user.getId() : null; + + int queued = 0; + int skipped = 0; + int unreachable = 0; + for (final QueueItem item : items) { + final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel()); + if (item.clientId() == null || channel == null || !channel.isPlatformSent()) { + throw validationError("channel", "Only SMS, Email and WhatsApp can be queued."); + } + final String type = item.noticeType() == null ? "" : item.noticeType().toUpperCase(); + if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) { + throw validationError("noticeType", "Notice type must be KYC or DORMANCY."); + } + // A customer with no mobile number (or no email, for Email) cannot be sent this. Queuing it would only write a + // "not sent" record every time, so it is left out and counted — those customers are found and reached another way. + final Map contact = this.jdbcTemplate.queryForList("SELECT mobile_no, email_address FROM m_client WHERE id = ?", + item.clientId()).stream().findFirst().orElse(null); + final String destination = contact == null ? null + : (String) contact.get(channel == KycContactChannel.EMAIL ? "email_address" : "mobile_no"); + if (StringUtils.isBlank(destination)) { + unreachable++; + continue; + } + // The same notice already waiting is not queued twice; a double click must not message a customer twice. + final Integer waiting = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_kyc_notice_queue WHERE client_id = ? " + + "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class, + item.clientId(), channel.name(), type); + if (waiting != null && waiting > 0) { + skipped++; + continue; + } + this.jdbcTemplate.update("INSERT INTO m_kyc_notice_queue (client_id, notice_type, channel, account_no, stage_label, notes, " + + "officer_name, queued_by_user_id, status, attempts, scheduled_for, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + item.clientId(), type, channel.name(), StringUtils.abbreviate(item.accountNo(), 50), + StringUtils.abbreviate(item.stageLabel(), 100), note, StringUtils.abbreviate(officerName, 200), userId, PENDING, + Timestamp.valueOf(when), Timestamp.valueOf(now)); + queued++; + } + return new EnqueueResult(queued, skipped, unreachable); + } + + @Override + public List> list(final String status, final int limit) { + final int rows = Math.min(Math.max(limit, 1), 500); + final StringBuilder sql = new StringBuilder("SELECT q.id, q.client_id, c.display_name, q.account_no, q.notice_type, q.channel, " + + "q.stage_label, q.status, q.attempts, q.result_message, q.scheduled_for, q.created_at, q.processed_at " + + "FROM m_kyc_notice_queue q JOIN m_client c ON c.id = q.client_id"); + final List args = new ArrayList<>(); + if (StringUtils.isNotBlank(status)) { + sql.append(" WHERE q.status = ?"); + args.add(status.trim().toUpperCase()); + } + sql.append(" ORDER BY q.id DESC LIMIT ").append(rows); + + final List> out = new ArrayList<>(); + for (final Map r : this.jdbcTemplate.queryForList(sql.toString(), args.toArray())) { + final Map item = new LinkedHashMap<>(); + item.put("id", r.get("id")); + item.put("clientId", r.get("client_id")); + item.put("clientName", r.get("display_name")); + item.put("accountNo", r.get("account_no")); + item.put("noticeType", r.get("notice_type")); + item.put("channel", r.get("channel")); + item.put("stageLabel", r.get("stage_label")); + item.put("status", r.get("status")); + item.put("attempts", r.get("attempts")); + item.put("resultMessage", r.get("result_message")); + item.put("scheduledFor", iso(r.get("scheduled_for"))); + item.put("createdAt", iso(r.get("created_at"))); + item.put("processedAt", iso(r.get("processed_at"))); + out.add(item); + } + return out; + } + + @Override + public void cancel(final Long id) { + final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ?, " + + "result_message = 'Cancelled before it was sent.' WHERE id = ? AND status = ?", CANCELLED, + Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant()), id, PENDING); + if (changed == 0) { + throw validationError("id", "Only a notice that is still waiting can be cancelled."); + } + } + + @Override + public void retry(final Long id) { + final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant()); + final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = 0, scheduled_for = ?, " + + "result_message = NULL, processed_at = NULL WHERE id = ? AND status = ?", PENDING, now, id, FAILED); + if (changed == 0) { + throw validationError("id", "Only a notice that failed can be retried."); + } + } + + @Override + public int processDue() { + final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant(); + + // Anything left half-sent by a stopped server goes back in line. + this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ? WHERE status = ? AND processed_at < ?", PENDING, SENDING, + Timestamp.valueOf(now.minusMinutes(STUCK_MINUTES))); + + final List> due = this.jdbcTemplate.queryForList("SELECT id, client_id, notice_type, channel, notes, " + + "officer_name, attempts FROM m_kyc_notice_queue WHERE status = ? AND scheduled_for <= ? ORDER BY id LIMIT " + BATCH, + PENDING, Timestamp.valueOf(now)); + + int sent = 0; + for (final Map row : due) { + final Long id = ((Number) row.get("id")).longValue(); + // Claim it first, so two overlapping runs cannot both send the same notice. + final int claimed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ? WHERE id = ? AND status = ?", + SENDING, Timestamp.valueOf(now), id, PENDING); + if (claimed == 0) { + continue; + } + final int attempts = ((Number) row.get("attempts")).intValue() + 1; + try { + // The stage is read when the notice goes out. A customer who has since transacted or renewed their KYC is + // no longer at a stage that has a notice, and telling them they are would be wrong — so it is dropped, not failed. + if (this.noticeComposer.compose(((Number) row.get("client_id")).longValue(), (String) row.get("notice_type")) == null) { + finish(id, CANCELLED, attempts, "Not sent — the customer is no longer at a stage that has a notice (they may have transacted or renewed their KYC since).", null, null); + continue; + } + final KycContactLogData result = this.contactLogService.sendStageNotice(((Number) row.get("client_id")).longValue(), + (String) row.get("channel"), (String) row.get("notes"), (String) row.get("officer_name"), + (String) row.get("notice_type")); + final String delivery = result.getDeliveryStatus(); + if ("SENT".equals(delivery)) { + finish(id, SENT, attempts, result.getDeliveryMessage(), result.getId(), null); + sent++; + } else if ("FAILED".equals(delivery) && attempts < MAX_ATTEMPTS) { + // The gateway refused it; try again shortly rather than give up on the first miss. + finish(id, PENDING, attempts, result.getDeliveryMessage(), result.getId(), + Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant().plusMinutes(RETRY_MINUTES))); + } else { + finish(id, FAILED, attempts, result.getDeliveryMessage(), result.getId(), null); + } + } catch (final RuntimeException e) { + log.warn("Queued KYC notice {} could not be sent: {}", id, e.getMessage()); + finish(id, FAILED, attempts, "Could not be sent: " + e.getMessage(), null, null); + } + } + if (!due.isEmpty()) { + log.info("[KYC notice queue] {} of {} due notice(s) sent", sent, due.size()); + } + return sent; + } + + private void finish(final Long id, final String status, final int attempts, final String message, final Long logId, + final Timestamp nextAttempt) { + final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant()); + if (nextAttempt != null) { + this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, " + + "processed_at = ?, scheduled_for = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now, + nextAttempt, id); + } else { + this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, " + + "processed_at = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now, id); + } + } + + private static String iso(final Object value) { + return value == null ? null : String.valueOf(value).replace(' ', 'T'); + } + + private PlatformApiDataValidationException validationError(final String parameter, final String message) { + final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.notice.queue." + parameter + ".invalid", + message, parameter); + return new PlatformApiDataValidationException(List.of(error)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderService.java new file mode 100644 index 000000000..d0f982ba3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderService.java @@ -0,0 +1,39 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +/** + * Sends customers the reminder that matches the stage they have reached, and says what happens to + * their account next. + */ +public interface KycReminderService { + + /** + * One pass over every customer whose re-KYC is within 30 days of falling due or already lapsed, + * and every customer with an inactive or dormant savings account. + * + *

+ * Each customer gets the latest stage notice they have not yet been sent, by SMS and by email, and + * a notice is never repeated for the same stage. Running the pass twice in a day, or after a + * missed night, sends nothing extra. + * + * @return how many notices were attempted + */ + int sendReminders(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderServiceImpl.java new file mode 100644 index 000000000..ad4530dd5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycReminderServiceImpl.java @@ -0,0 +1,184 @@ +/** + * 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.client.service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashMap; +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.client.domain.ClientKyc; +import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository; +import org.apache.fineract.portfolio.client.domain.ClientKycRepository; +import org.apache.fineract.portfolio.client.domain.ClientKycStatus; +import org.apache.fineract.portfolio.client.domain.KycContactChannel; +import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus; +import org.apache.fineract.portfolio.savings.data.DormantAccountData; +import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService; +import org.springframework.stereotype.Service; + +/** + * The nightly reminder pass. + * + *

+ * The restriction job ({@link KycExpiryRestrictionService}) acts on accounts; this one tells the + * customer first, and tells them what comes next. A customer is therefore never surprised by a + * freeze they were not warned of. + * + *

+ * The stage a customer is at is worked out from today's date every run rather than remembered, and + * the latest stage they have reached is the one they are told about. A missed night therefore costs + * nothing: the next run sends the current stage, not the stale one. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KycReminderServiceImpl implements KycReminderService { + + /** Days from expiry (negative = before) at which a customer's KYC stage changes. */ + /** + * Days from expiry (negative = before) at which a KYC reminder goes out: 30, 15, 7 and 1 day before it expires, the + * day it expires, then 7 and 1 day before debits stop (day 90) and before the account goes inoperative (day 180), and + * on each of those two days themselves. + */ + private static final int[] KYC_MILESTONES = { -30, -15, -7, -1, 0, 83, 89, 90, 173, 179, 180 }; + + /** An account is warned 7 days and 1 day before it moves to its next stage. */ + private static final long WARN_DAYS = 7; + + private static final int SUB_STATUS_INACTIVE = 100; + private static final int SUB_STATUS_DORMANT = 200; + + /** A failing gateway is retried on later nights, but not for ever. */ + private static final long MAX_ATTEMPTS = 3; + + private static final String OFFICER = "System — automatic reminder"; + private static final List CHANNELS = List.of(KycContactChannel.SMS, KycContactChannel.EMAIL); + + private final ClientKycRepository kycRepository; + private final ClientKycContactLogRepository contactLogRepository; + private final KycContactLogService contactLogService; + private final DormantAccountReadPlatformService dormantAccountReadPlatformService; + + @Override + public int sendReminders() { + final LocalDate today = DateUtils.getBusinessLocalDate(); + int attempted = 0; + + final List records = new ArrayList<>( + kycRepository.findExpiringBetween(today, today.plusDays(-KYC_MILESTONES[0]), ClientKycStatus.REJECTED.getValue())); + records.addAll(kycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue())); + for (final ClientKyc kyc : records) { + try { + attempted += remindKyc(kyc, today); + } catch (final RuntimeException e) { + // One customer's failure must not stop the rest of the night's reminders. + log.warn("KYC reminder for client {} failed: {}", kyc.getClient() != null ? kyc.getClient().getId() : null, + e.getMessage()); + } + } + + attempted += remindDormant(today); + log.info("[KYC reminders] {} notice(s) attempted", attempted); + return attempted; + } + + // ── Re-KYC ───────────────────────────────────────────────────────────────── + + private int remindKyc(final ClientKyc kyc, final LocalDate today) { + if (kyc.getClient() == null || kyc.getExpiryDate() == null) { + return 0; + } + final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today); + Integer milestone = null; + for (final int m : KYC_MILESTONES) { + if (offset >= m) { + milestone = m; + } + } + if (milestone == null) { + return 0; + } + // The expiry date is in the marker so a renewed KYC starts a fresh set of reminders. + final String marker = "[AUTO KYC " + kyc.getExpiryDate() + " M" + milestone + "]"; + return sendBoth(kyc.getClient().getId(), marker, KycNoticeComposer.kycSubject(offset), + KycNoticeComposer.kycMessage(kyc.getExpiryDate(), offset)); + } + + // ── Inactive / dormant accounts ──────────────────────────────────────────── + + private int remindDormant(final LocalDate today) { + // One notice per customer, about the account that has been quiet longest. + final Map worst = new LinkedHashMap<>(); + for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) { + final Integer sub = account.getSubStatus(); + if (account.getClientId() == null || sub == null || (sub != SUB_STATUS_INACTIVE && sub != SUB_STATUS_DORMANT)) { + continue; // escheated accounts are closed; there is nothing left to warn about + } + final DormantAccountData held = worst.get(account.getClientId()); + if (held == null || idle(account) > idle(held)) { + worst.put(account.getClientId(), account); + } + } + + int attempted = 0; + for (final DormantAccountData account : worst.values()) { + try { + // The step is "E" (the account has just entered this stage), "D7" (a week before the next) or "D1" (the day + // before). The latest one reached is what the customer is told, once. + final Long remaining = account.getSubStatus() == SUB_STATUS_DORMANT ? account.getDaysToEscheat() : account.getDaysToDormancy(); + final String step = remaining != null && remaining <= 1 ? "D1" : remaining != null && remaining <= WARN_DAYS ? "D7" : "E"; + final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + " " + step + "]"; + final String subject = KycNoticeComposer.dormantSubject(account); + attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today)); + } catch (final RuntimeException e) { + log.warn("Dormancy reminder for client {} failed: {}", account.getClientId(), e.getMessage()); + } + } + return attempted; + } + + private static long idle(final DormantAccountData account) { + return account.getDaysIdle() == null ? 0 : account.getDaysIdle(); + } + + // ── Sending ──────────────────────────────────────────────────────────────── + + /** Sends the message by every channel, skipping any channel this stage has already been told on. */ + private int sendBoth(final Long clientId, final String marker, final String subject, final String body) { + int attempted = 0; + for (final KycContactChannel channel : CHANNELS) { + // Delivered, or nothing to deliver to, is final. A failure is retried, a few times only. + final boolean settled = contactLogRepository.existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(clientId, + channel, marker, EnumSet.of(KycContactDeliveryStatus.SENT, KycContactDeliveryStatus.SKIPPED_NO_DESTINATION)); + if (settled || contactLogRepository.countByClientIdAndChannelAndNotesStartingWith(clientId, channel, marker) >= MAX_ATTEMPTS) { + continue; + } + contactLogService.sendNotice(clientId, channel.name(), marker + " Automatic reminder by " + channel.getDisplayLabel() + ".", + OFFICER, subject, body); + attempted++; + } + return attempted; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java index 4e14e91fc..31fb5db9e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java @@ -92,7 +92,8 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null, txnRule != null ? txnRule.getThresholdAmountHighRisk() : null, txnRule != null ? txnRule.getMinLimitAmount() : null, - txnRule != null ? txnRule.getMaxLimitAmount() : null + txnRule != null ? txnRule.getMaxLimitAmount() : null, + rule.isCcRelevant() ); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java index f235231fb..462f1f65a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java @@ -64,7 +64,9 @@ public class ClientSearchService { Pageable pageable = searchRequest.toPageable(); - return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue) + Long officeId = request.map(ClientTextSearch::getOfficeId).orElse(null); + + return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue, officeId) .map(clientSearchDataMapper::map); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java index e9758a0a8..f294f4e65 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java @@ -41,4 +41,11 @@ public class ClientTextSearch { * not be able to link entities by omitting the param). Defaults to null = all legal forms. */ private String legalFormType; + + /** + * Narrows results to one branch (office). It is applied on top of the caller's office-hierarchy + * restriction, never instead of it, so a branch user passing another branch's id still gets nothing. + * Used by the client list's branch filter for Head Office users. Null = no extra filter. + */ + private Long officeId; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/Note.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/Note.java index db7151717..c7f0a26f8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/Note.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/Note.java @@ -28,6 +28,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; @@ -73,6 +74,10 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom { @JoinColumn(name = "share_account_id", nullable = true) private ShareAccount shareAccount; + @ManyToOne + @JoinColumn(name = "current_account_id", nullable = true) + private CurrentAccount currentAccount; + public static Note clientNote(final Client client, final String note) { return new Note(client, note); } @@ -110,6 +115,10 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom { return new Note(account, note); } + public static Note currentAccountNote(final CurrentAccount account, final String note) { + return new Note(account, note); + } + public Note(final Client client, final String note) { this.client = client; this.note = note; @@ -161,6 +170,13 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom { this.noteTypeId = NoteType.SHARE_ACCOUNT.getValue(); } + private Note(final CurrentAccount account, final String note) { + this.currentAccount = account; + this.client = account.getClient(); + this.note = note; + this.noteTypeId = NoteType.CURRENT_ACCOUNT.getValue(); + } + public Map update(final String note) { if (!Strings.CI.equals(note, this.note)) { this.note = StringUtils.defaultIfEmpty(note, null); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/NoteRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/NoteRepository.java index 4fcf398a2..b25070a99 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/NoteRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/domain/NoteRepository.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.note.domain; import java.util.List; import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; @@ -50,6 +51,8 @@ public interface NoteRepository extends JpaRepository, JpaSpecificat Note findBySavingsAccountAndId(SavingsAccount savingAccount, Long id); + Note findByCurrentAccountAndId(CurrentAccount currentAccount, Long id); + @Query("select note from Note note where note.savingsTransaction.id = :savingsTransactionId") List findBySavingsTransactionId(@Param("savingsTransactionId") Long savingsTransactionId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteReadPlatformServiceImpl.java index d28042c08..2f61ab0cb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteReadPlatformServiceImpl.java @@ -137,6 +137,10 @@ public class NoteReadPlatformServiceImpl implements NoteReadPlatformService { case GROUP: conditionSql = " n.group_id = ? "; break; + case CURRENT_ACCOUNT: + paramList.add(NoteType.CURRENT_ACCOUNT.getValue()); + conditionSql = " n.current_account_id = ? and n.note_type_enum = ? "; + break; default: break; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteWritePlatformServiceJpaRepositoryImpl.java index 62158bc44..6b30846e2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/service/NoteWritePlatformServiceJpaRepositoryImpl.java @@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.tuple.Pair; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepositoryWrapper; import org.apache.fineract.portfolio.group.domain.GroupRepository; import org.apache.fineract.portfolio.group.exception.GroupNotFoundException; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; @@ -50,16 +51,19 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf private final LoanRepositoryWrapper loanRepository; private final LoanTransactionRepository loanTransactionRepository; private final SavingsAccountRepository savingsAccountRepository; + private final CurrentAccountRepositoryWrapper currentAccountRepository; public NoteWritePlatformServiceJpaRepositoryImpl(final NoteRepository noteRepository, final ClientRepositoryWrapper clientRepository, final GroupRepository groupRepository, final LoanRepositoryWrapper loanRepository, - final LoanTransactionRepository loanTransactionRepository, final SavingsAccountRepository savingsAccountRepository) { + final LoanTransactionRepository loanTransactionRepository, final SavingsAccountRepository savingsAccountRepository, + final CurrentAccountRepositoryWrapper currentAccountRepository) { this.noteRepository = noteRepository; this.clientRepository = clientRepository; this.groupRepository = groupRepository; this.loanRepository = loanRepository; this.loanTransactionRepository = loanTransactionRepository; this.savingsAccountRepository = savingsAccountRepository; + this.currentAccountRepository = currentAccountRepository; } @Override @@ -101,6 +105,12 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf officeId = savingAccount.getClient().getOffice().getId(); } break; + case CURRENT_ACCOUNT: { + final var currentAccount = currentAccountRepository.findOneWithNotFoundDetection(request.getResourceId()); + note = noteRepository.saveAndFlush(Note.currentAccountNote(currentAccount, request.getNote())); + officeId = currentAccount.getOffice().getId(); + } + break; default: throw new NoteResourceNotSupportedException(request.getType().getApiUrl()); } @@ -168,6 +178,12 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf officeId = savingAccount.getClient().getOffice().getId(); } break; + case CURRENT_ACCOUNT: { + final var currentAccount = currentAccountRepository.findOneWithNotFoundDetection(resourceId); + note = noteRepository.findByCurrentAccountAndId(currentAccount, noteId); + officeId = currentAccount.getOffice().getId(); + } + break; case SHARE_ACCOUNT: case SAVINGS_TRANSACTION: log.error("Not yet implemented: {}", type); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/starter/NoteAutoConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/starter/NoteAutoConfiguration.java index c61fd8f8d..385cea6ac 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/starter/NoteAutoConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/note/starter/NoteAutoConfiguration.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.note.starter; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepositoryWrapper; import org.apache.fineract.portfolio.group.domain.GroupRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; @@ -46,8 +47,8 @@ public class NoteAutoConfiguration { @ConditionalOnMissingBean public NoteWritePlatformService noteWritePlatformService(NoteRepository noteRepository, ClientRepositoryWrapper clientRepository, GroupRepository groupRepository, LoanRepositoryWrapper loanRepository, LoanTransactionRepository loanTransactionRepository, - SavingsAccountRepository savingsAccountRepository) { + SavingsAccountRepository savingsAccountRepository, CurrentAccountRepositoryWrapper currentAccountRepository) { return new NoteWritePlatformServiceJpaRepositoryImpl(noteRepository, clientRepository, groupRepository, loanRepository, - loanTransactionRepository, savingsAccountRepository); + loanTransactionRepository, savingsAccountRepository, currentAccountRepository); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/api/ProfitAppropriationConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/api/ProfitAppropriationConfigApiResource.java new file mode 100644 index 000000000..b43f3a7eb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/api/ProfitAppropriationConfigApiResource.java @@ -0,0 +1,261 @@ +/** + * 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.profitappropriation.api; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +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.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +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.portfolio.profitappropriation.service.ProfitAppropriationPostingService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reserve funds and the percentage of the year's net profit appropriated to each - Statutory Reserve, Building + * Fund, Investment Depreciation Reserve and so on - with the GL account each reserve is credited to. + * + * The screen saves the whole list at once (see {@link #update}), so the stored set is always exactly what one + * administrator chose, never an interleaving of two open screens. + */ +@Path("/v1/profitappropriationconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class ProfitAppropriationConfigApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "PROFITAPPROPRIATIONCONFIG"; + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + private static final int MAX_NAME_LENGTH = 100; + // Reserves and funds are equity (3) or, for funds held for a purpose, liability (2) accounts. + private static final String ELIGIBLE_GL_SQL = "disabled = false AND account_usage = 1 AND classification_enum IN (2, 3)"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final ProfitAppropriationPostingService postingService; + private final Gson gson = new Gson(); + + @GET + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + return gson.toJson(configuration()); + } + + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + // Header accounts carry no entries of their own, so only postable accounts can receive an appropriation. + final List> accounts = jdbcTemplate.queryForList( + "SELECT id, name, gl_code, classification_enum FROM acc_gl_account WHERE " + ELIGIBLE_GL_SQL + " 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)); + } + + /** + * Replaces the whole list. Body: {@code {"reserves":[{"name":"Statutory Reserve","glAccountId":5,"percentage":25}]}}. + * A reserve may be saved without a GL account while the bank has not opened that account yet. + */ + @PUT + @Consumes({ MediaType.APPLICATION_JSON }) + @Transactional + public String update(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final JsonArray rows = root != null && root.isJsonObject() && root.getAsJsonObject().has("reserves") + && root.getAsJsonObject().get("reserves").isJsonArray() ? root.getAsJsonObject().getAsJsonArray("reserves") : null; + if (rows == null) { + throw invalid("reserves.required", "The request must contain a 'reserves' list."); + } + + final List reserves = new ArrayList<>(); + final Set names = new HashSet<>(); + final Set accounts = new HashSet<>(); + BigDecimal total = BigDecimal.ZERO; + + for (final JsonElement element : rows) { + final JsonObject row = element.getAsJsonObject(); + final String name = StringUtils.trimToNull(text(row, "name")); + if (name == null) { + throw invalid("name.required", "Every reserve needs a name."); + } + if (name.length() > MAX_NAME_LENGTH) { + throw invalid("name.too.long", "Reserve name '" + name + "' is longer than " + MAX_NAME_LENGTH + " characters."); + } + if (!names.add(name.toLowerCase(Locale.ROOT))) { + throw invalid("name.duplicate", "Reserve '" + name + "' is listed twice."); + } + + final BigDecimal percentage = row.has("percentage") && !row.get("percentage").isJsonNull() + ? row.get("percentage").getAsBigDecimal() + : null; + if (percentage == null || percentage.signum() <= 0 || percentage.compareTo(HUNDRED) > 0) { + throw invalid("percentage.out.of.range", "The percentage for '" + name + "' must be more than 0 and at most 100."); + } + total = total.add(percentage); + + final Long glAccountId = row.has("glAccountId") && !row.get("glAccountId").isJsonNull() ? row.get("glAccountId").getAsLong() + : null; + if (glAccountId != null) { + final Integer eligible = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND " + ELIGIBLE_GL_SQL, Integer.class, glAccountId); + if (eligible == null || eligible == 0) { + throw invalid("gl.account.not.eligible", "The GL account chosen for '" + name + + "' was not found, is disabled, is a header account, or is not an equity or liability account."); + } + // Two reserves credited to one account would merge into a single balance nobody can split again. + if (!accounts.add(glAccountId)) { + throw invalid("gl.account.duplicate", "The GL account chosen for '" + name + "' is already used by another reserve."); + } + } + reserves.add(new Reserve(name, glAccountId, percentage)); + } + + // More than 100% would appropriate more than the year earned. + if (total.compareTo(HUNDRED) > 0) { + throw invalid("total.exceeds.hundred", + "The reserves add up to " + total.stripTrailingZeros().toPlainString() + "% of net profit; the total cannot exceed 100%."); + } + + jdbcTemplate.update("DELETE FROM m_profit_appropriation_config"); + int position = 0; + for (final Reserve reserve : reserves) { + jdbcTemplate.update(""" + INSERT INTO m_profit_appropriation_config (name, gl_account_id, percentage, position, updated_on, updated_by) + VALUES (?, ?, ?, ?, now(), ?) + """, reserve.name(), reserve.glAccountId(), reserve.percentage(), position++, user.getId()); + } + return gson.toJson(configuration()); + } + + /** + * What appropriating a year's profit would post - each reserve's amount from the profit GL - or, for a year already + * appropriated, what was posted. {@code financialYear} defaults to the newest closed year not yet appropriated. + */ + @GET + @Path("posting") + public String postingPreview(@QueryParam("financialYear") final String financialYear) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + final String fy = StringUtils.isBlank(financialYear) ? postingService.defaultFinancialYear() : financialYear.trim(); + return gson.toJson(postingService.preview(fy)); + } + + /** Posts the year's appropriation. Body: {@code {"financialYear":"2026-27","note":"AGM resolution 12/2027"}}. */ + @POST + @Path("posting") + @Consumes({ MediaType.APPLICATION_JSON }) + public String post(final String body) { + final AppUser user = this.context.authenticatedUser(); + // Configuring the reserves is not enough - this posts journal entries, so it needs that right as well. + user.validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + user.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonElement root = this.fromJsonHelper.parse(body); + final JsonObject json = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject(); + final String financialYear = StringUtils.trimToNull(text(json, "financialYear")); + if (financialYear == null) { + throw invalid("financial.year.required", "Choose the financial year whose profit is to be appropriated."); + } + return gson.toJson(postingService.post(financialYear, text(json, "note"), user.getId(), user.getUsername())); + } + + private Map configuration() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id, c.name, c.gl_account_id, g.gl_code, g.name AS gl_name, c.percentage, c.position, + c.updated_on, u.username AS updated_by + FROM m_profit_appropriation_config c + LEFT JOIN acc_gl_account g ON g.id = c.gl_account_id + LEFT JOIN m_appuser u ON u.id = c.updated_by + ORDER BY c.position, c.id + """); + final List> reserves = new ArrayList<>(); + BigDecimal total = BigDecimal.ZERO; + Object updatedOn = null; + Object updatedBy = null; + for (final Map r : rows) { + final BigDecimal percentage = (BigDecimal) r.get("percentage"); + total = total.add(percentage); + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("name", r.get("name")); + o.put("glAccountId", r.get("gl_account_id")); + o.put("glCode", r.get("gl_code")); + o.put("glAccountName", r.get("gl_name")); + o.put("percentage", plain(percentage)); + reserves.add(o); + updatedOn = r.get("updated_on") != null ? r.get("updated_on").toString() : updatedOn; + updatedBy = r.get("updated_by") != null ? r.get("updated_by") : updatedBy; + } + final Map out = new LinkedHashMap<>(); + out.put("reserves", reserves); + out.put("totalPercentage", plain(total)); + out.put("remainingPercentage", plain(HUNDRED.subtract(total))); + out.put("updatedOn", updatedOn); + out.put("updatedBy", updatedBy); + return out; + } + + /** 25.0000 -> 25 and 100.0000 -> 100 (not 1E+2), so the screen shows what was typed. */ + private static BigDecimal plain(final BigDecimal value) { + return new BigDecimal(value.stripTrailingZeros().toPlainString()); + } + + private static String text(final JsonObject row, final String field) { + return row.has(field) && !row.get(field).isJsonNull() ? row.get(field).getAsString() : null; + } + + private static PlatformDataIntegrityException invalid(final String code, final String message) { + return new PlatformDataIntegrityException("error.msg.profitappropriationconfig." + code, message); + } + + private record Reserve(String name, Long glAccountId, BigDecimal percentage) {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/service/ProfitAppropriationPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/service/ProfitAppropriationPostingService.java new file mode 100644 index 000000000..d100d77c7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/profitappropriation/service/ProfitAppropriationPostingService.java @@ -0,0 +1,268 @@ +/** + * 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.profitappropriation.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; +import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService; +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; + +/** + * Appropriates a closed financial year's profit into the reserve funds configured on the Reserve Fund + * Configuration screen: each reserve gets its percentage of the profit in the Distributable Profit GL, posted as + * one journal entry ({@code Dr profit GL / Cr reserve GL} per reserve), dated the last day of the year so it sits in + * the same year as the close it follows. + * + * Refused until the year has been closed into the profit GL (before that the GL holds a partial figure), while any + * reserve has no GL account, and once the year has been appropriated - each year's profit is appropriated once. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProfitAppropriationPostingService { + + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + private static final int MAX_NOTE_LENGTH = 500; + + private final JdbcTemplate jdbcTemplate; + private final IncomeProfitTransferService yearEnd; + + /** The financial years the screen offers: the current one and the three before it, newest first. */ + public List financialYearOptions() { + final String current = yearEnd.currentFinancialYear(); + final int startYear = Integer.parseInt(current.substring(0, 4)); + final List years = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + final int y = startYear - i; + years.add(y + "-" + String.format("%02d", (y + 1) % 100)); + } + return years; + } + + /** The year the screen opens on: the newest closed year not yet appropriated, else the year before the current. */ + public String defaultFinancialYear() { + final List years = financialYearOptions(); + for (final String fy : years) { + if (yearEnd.isYearClosed(fy) && !isPosted(fy)) { + return fy; + } + } + return years.get(1); + } + + /** What posting the year would do - or, for a year already appropriated, what was posted. */ + public Map preview(final String financialYear) { + final LocalDate[] range = yearEnd.financialYearRange(financialYear); + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYear); + out.put("fromDate", range[0].toString()); + out.put("toDate", range[1].toString()); + out.put("financialYearOptions", financialYearOptions()); + + final List blockers = new ArrayList<>(); + + final List> posted = postedRows(financialYear); + if (!posted.isEmpty()) { + final Map first = posted.get(0); + out.put("posted", true); + out.put("profitAmount", plain((BigDecimal) first.get("profit_amount"))); + out.put("glTransactionId", first.get("gl_transaction_id")); + out.put("postedOnDate", String.valueOf(first.get("posted_on_date"))); + out.put("postedBy", first.get("posted_by_name")); + out.put("note", first.get("note")); + out.put("profitGlCode", first.get("profit_gl_code")); + out.put("profitGlName", first.get("profit_gl_name")); + BigDecimal total = BigDecimal.ZERO; + final List> lines = new ArrayList<>(); + for (final Map r : posted) { + final BigDecimal amount = (BigDecimal) r.get("amount"); + total = total.add(amount); + lines.add(line((String) r.get("reserve_name"), r.get("gl_account_id"), r.get("gl_code"), r.get("gl_name"), + (BigDecimal) r.get("percentage"), amount)); + } + out.put("lines", lines); + out.put("totalAmount", plain(total)); + out.put("remainingAmount", plain(((BigDecimal) first.get("profit_amount")).subtract(total))); + out.put("blockers", List.of("Profit for " + financialYear + " has already been appropriated.")); + out.put("canPost", false); + return out; + } + out.put("posted", false); + + Map profitGl = null; + try { + profitGl = yearEnd.loadDistributableProfitGl(); + out.put("profitGlCode", profitGl.get("gl_code")); + out.put("profitGlName", profitGl.get("name")); + } catch (RuntimeException e) { + blockers.add("No GL is mapped to Distributable Profit (Accounting → Accounts Linked to Financial Activities)."); + } + + final boolean closed = yearEnd.isYearClosed(financialYear); + out.put("yearClosed", closed); + if (!closed) { + blockers.add("FY " + financialYear + " has not been closed into the profit GL yet. The year-end close runs on " + + range[1] + "; until then the profit GL does not hold the year's profit."); + } + + final BigDecimal profit = profitGl != null ? yearEnd.profitForFinancialYear(financialYear) : BigDecimal.ZERO; + out.put("profitAmount", plain(profit)); + if (profitGl != null && closed && profit.signum() <= 0) { + blockers.add("There is no profit to appropriate for " + financialYear + " (the profit GL shows " + plain(profit) + ")."); + } + + final List> reserves = jdbcTemplate.queryForList(""" + SELECT c.name, c.gl_account_id, g.gl_code, g.name AS gl_name, c.percentage + FROM m_profit_appropriation_config c + LEFT JOIN acc_gl_account g ON g.id = c.gl_account_id + ORDER BY c.position, c.id + """); + if (reserves.isEmpty()) { + blockers.add("No reserve fund is configured."); + } + final List> lines = new ArrayList<>(); + final List unmapped = new ArrayList<>(); + BigDecimal total = BigDecimal.ZERO; + for (final Map r : reserves) { + final BigDecimal percentage = (BigDecimal) r.get("percentage"); + final BigDecimal amount = profit.signum() > 0 ? share(profit, percentage) : BigDecimal.ZERO; + total = total.add(amount); + if (r.get("gl_account_id") == null) { + unmapped.add((String) r.get("name")); + } + lines.add(line((String) r.get("name"), r.get("gl_account_id"), r.get("gl_code"), r.get("gl_name"), percentage, amount)); + } + if (!unmapped.isEmpty()) { + blockers.add("Select a GL account for: " + String.join(", ", unmapped) + "."); + } + out.put("lines", lines); + out.put("totalAmount", plain(total)); + out.put("remainingAmount", plain(profit.subtract(total))); + out.put("blockers", blockers); + out.put("canPost", blockers.isEmpty()); + return out; + } + + /** Posts the year's appropriation as one journal entry and records it. */ + @Transactional + public Map post(final String financialYear, final String note, final Long userId, final String userName) { + // Serialises two users posting the same year at once: the second waits here, then sees the first's rows. + jdbcTemplate.execute("LOCK TABLE m_profit_appropriation_posting IN SHARE ROW EXCLUSIVE MODE"); + + final Map preview = preview(financialYear); + if (!Boolean.TRUE.equals(preview.get("canPost"))) { + @SuppressWarnings("unchecked") + final List blockers = (List) preview.get("blockers"); + throw new PlatformDataIntegrityException("error.msg.profitappropriation.cannot.post", + blockers.isEmpty() ? "Profit for " + financialYear + " cannot be appropriated." : blockers.get(0)); + } + + final Map profitGl = yearEnd.loadDistributableProfitGl(); + final Long profitGlId = ((Number) profitGl.get("id")).longValue(); + final BigDecimal profit = new BigDecimal(preview.get("profitAmount").toString()); + final LocalDate postingDate = yearEnd.financialYearRange(financialYear)[1]; + final Long officeId = yearEnd.headOfficeId(); + final String currency = yearEnd.resolveCurrency(profitGlId); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.PROFIT_APPROPRIATION); + final String trimmedNote = StringUtils.abbreviate(StringUtils.trimToNull(note), MAX_NOTE_LENGTH); + final int debit = JournalEntryType.DEBIT.getValue(); + final int credit = JournalEntryType.CREDIT.getValue(); + + @SuppressWarnings("unchecked") + final List> lines = (List>) preview.get("lines"); + for (final Map l : lines) { + final String reserve = (String) l.get("reserveName"); + final Long glId = ((Number) l.get("glAccountId")).longValue(); + final BigDecimal amount = (BigDecimal) l.get("amount"); + if (amount.signum() > 0) { + final String descr = "Profit appropriation " + financialYear + " — " + reserve + + (trimmedNote != null ? " | " + trimmedNote : ""); + yearEnd.insertEntry(officeId, profitGlId, debit, amount, txnId, postingDate, userId, now, currency, descr); + yearEnd.insertEntry(officeId, glId, credit, amount, txnId, postingDate, userId, now, currency, descr); + } + jdbcTemplate.update(""" + INSERT INTO m_profit_appropriation_posting + (financial_year, reserve_name, gl_account_id, profit_gl_id, percentage, profit_amount, amount, + gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?, now()) + """, financialYear, reserve, glId, profitGlId, l.get("percentage"), profit, amount, txnId, + java.sql.Date.valueOf(postingDate), userId, userName, trimmedNote); + } + log.info("Profit appropriation {} posted as {}: {} of {} to {} reserve(s)", financialYear, txnId, preview.get("totalAmount"), + profit, lines.size()); + return preview(financialYear); + } + + private boolean isPosted(final String financialYear) { + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_profit_appropriation_posting WHERE financial_year = ?", + Integer.class, financialYear); + return count != null && count > 0; + } + + private List> postedRows(final String financialYear) { + return jdbcTemplate.queryForList(""" + SELECT p.reserve_name, p.gl_account_id, g.gl_code, g.name AS gl_name, p.percentage, p.profit_amount, p.amount, + p.gl_transaction_id, p.posted_on_date, p.posted_by_name, p.note, + pg.gl_code AS profit_gl_code, pg.name AS profit_gl_name + FROM m_profit_appropriation_posting p + JOIN acc_gl_account g ON g.id = p.gl_account_id + JOIN acc_gl_account pg ON pg.id = p.profit_gl_id + WHERE p.financial_year = ? + ORDER BY p.id + """, financialYear); + } + + /** A reserve's share of the profit, to the paisa. */ + private static BigDecimal share(final BigDecimal profit, final BigDecimal percentage) { + return profit.multiply(percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + } + + private static Map line(final String name, final Object glId, final Object glCode, final Object glName, + final BigDecimal percentage, final BigDecimal amount) { + final Map l = new LinkedHashMap<>(); + l.put("reserveName", name); + l.put("glAccountId", glId); + l.put("glCode", glCode); + l.put("glAccountName", glName); + l.put("percentage", plain(percentage)); + l.put("amount", plain(amount)); + return l; + } + + private static BigDecimal plain(final BigDecimal value) { + return new BigDecimal(value.stripTrailingZeros().toPlainString()); + } +} 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 5481aaaf6..596a1527d 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 @@ -19,7 +19,12 @@ package org.apache.fineract.portfolio.rbireserve.api; import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; @@ -28,6 +33,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.HashSet; @@ -36,6 +42,12 @@ import java.util.List; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.SynchronousCommandProcessingService; +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.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -129,6 +141,15 @@ public class RbiReserveReturnApiResource { private static final String USAGE_SLR_ASSET = "SLR_ASSET"; private final JdbcTemplate jdbcTemplate; + /** + * Raises the transfer entry in Java rather than over HTTP. + * + * The manual journal entry endpoint is turned off at the HTTP edge on this installation; an + * internal command is how the clearing and disbursement flows post theirs, and it goes through + * maker-checker on exactly the same terms. + */ + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final FineractRequestContextHolder fineractRequestContextHolder; private final PlatformSecurityContext context; private final Gson gson = new Gson(); @@ -765,10 +786,19 @@ public class RbiReserveReturnApiResource { final BigDecimal percentage; final boolean seedDefault; + /** + * The date this rate came into force. + * + * Carried with the rate because a return read day by day has to say WHEN a requirement + * changed, not only what it changed to: a rate rising mid-period leaves two requirements + * on one sheet, and without the date a reader cannot tell which days each one settled. + */ + final LocalDate effectiveFrom; - Rate(final BigDecimal percentage, final boolean seedDefault) { + Rate(final BigDecimal percentage, final boolean seedDefault, final LocalDate effectiveFrom) { this.percentage = percentage; this.seedDefault = seedDefault; + this.effectiveFrom = effectiveFrom; } } @@ -779,7 +809,7 @@ public class RbiReserveReturnApiResource { */ private Rate rateInForce(final String rateType, final LocalDate onDate) { final List> found = jdbcTemplate.queryForList(""" - SELECT percentage, is_seed_default FROM m_rbi_reserve_rate + SELECT percentage, is_seed_default, effective_from FROM m_rbi_reserve_rate WHERE rate_type = ? AND effective_from <= ? ORDER BY effective_from DESC LIMIT 1 """, rateType, java.sql.Date.valueOf(onDate)); @@ -787,7 +817,8 @@ public class RbiReserveReturnApiResource { return null; } final Map row = found.get(0); - return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default"))); + return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default")), + ((java.sql.Date) row.get("effective_from")).toLocalDate()); } /** @@ -1134,6 +1165,388 @@ public class RbiReserveReturnApiResource { * 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. */ + + /** + * Today's reserve position - what the bank must hold, what it is holding, and where. + * + *

+ * The return above reports fortnight closes. This answers a different question, and the one a + * treasury desk asks every morning: as things stand RIGHT NOW, are we short? A return made up to + * a close two weeks ago cannot say that, because the balances it reports have since moved. + * + *

+ * The requirement is still charged on the base date - RBI's lag is a rule, not an approximation, + * and today's own liabilities settle nothing until they become a base themselves. So the figures + * are read on two different dates on purpose: the base from the close it is charged on, the + * balances as they stand today. Both dates are stated, because a reader comparing them has to + * know they are not the same day. + * + *

+ * The accounts are listed individually rather than only totalled: a shortfall is covered by + * moving money into one of them, and a desk cannot act on a total. + */ + @GET + @Path("position") + public String reservePosition(@QueryParam("onDate") final String onDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate today = parseOrBusinessDate(onDateParam); + final LocalDate baseDate = formOneBaseDate(today); + final Rate crr = rateInForce("CRR", today); + final Rate slr = rateInForce("SLR", today); + + final Map response = new LinkedHashMap<>(); + response.put("onDate", today.toString()); + response.put("baseDate", baseDate.toString()); + response.put("crrPercentage", crr == null ? null : crr.percentage); + response.put("crrEffectiveFrom", crr == null ? null : crr.effectiveFrom.toString()); + response.put("slrPercentage", slr == null ? null : slr.percentage); + response.put("slrEffectiveFrom", slr == null ? null : slr.effectiveFrom.toString()); + + // The liabilities as they stand today, split the way the form splits them. Not what the + // requirement is charged on - that is the base below - but what a desk watches, because it + // is what the NEXT base will be struck from. + final RoleFigure dtlToday = figureFor("DEMAND_TIME_LIABILITIES", today, officeId); + final Map liabilities = new LinkedHashMap<>(); + liabilities.put("demand", dtlToday.demandAmount); + liabilities.put("time", dtlToday.timeAmount); + liabilities.put("total", dtlToday.amount); + liabilities.put("net", formOneNetLiabilities(today, officeId)); + response.put("today", liabilities); + + // The base the requirement is actually charged on, split the same way so the two can be + // compared line for line. + final RoleFigure dtlBase = figureFor("DEMAND_TIME_LIABILITIES", baseDate, officeId); + final BigDecimal base = formOneNetLiabilities(baseDate, officeId); + final Map charged = new LinkedHashMap<>(); + charged.put("demand", dtlBase.demandAmount); + charged.put("time", dtlBase.timeAmount); + charged.put("total", dtlBase.amount); + charged.put("net", base); + response.put("base", charged); + + 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); + + // What is actually held, as of today. The same definition Part B and Part C use, so the + // dashboard and the return cannot disagree about whether the bank is compliant. + final BigDecimal crrHeld = formOnePartBMaintained(today, officeId); + final BigDecimal slrHeld = formOneSlrHeld(today, crrHeld, crrRequired, officeId); + + response.put("crr", formOneRatioPosition(crrRequired, crrHeld)); + response.put("slr", formOneRatioPosition(slrRequired, slrHeld)); + + // Every account the cash reserve is maintained in, with what it holds today. These are the + // accounts a shortfall is covered INTO, so the balance that matters is today's, not the + // close's. + // + // The RBI current account is listed even where the return leaves item VI(a) empty: money + // can be moved there whatever the filed form shows, and a transfer screen that hid the + // account would leave a desk unable to use it. + final List> accounts = new ArrayList<>(); + accounts.addAll(formOnePositionAccounts("V", "Cash on hand", "CASH_IN_HAND", today, officeId)); + accounts.addAll( + formOnePositionAccounts("VI(a)", "Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", today, officeId)); + accounts.addAll(formOnePositionAccounts("VI(b)", "State Co-operative Bank", "FORM1_VI_B_STATE_COOP_CURRENT", + today, officeId)); + accounts.addAll(formOnePositionAccounts("VI(c)", "District Central Co-operative Bank", + "FORM1_VI_C_DCCB_CURRENT", today, officeId)); + + // Item VIII — the correspondent balances, which item X counts as surely as the cash and the + // current accounts above (X = V + VI + VIII). Left out, the accounts on screen added to + // less than the maintained figure beside them, and a desk reading the list could not see + // where the rest of its reserve was sitting. + // + // The balances themselves, not the net: item VIII reports only the excess over what the + // same banks hold with this bank, but what a desk can MOVE is the balance, and a row + // showing a net it cannot transfer is not a row it can act on. + accounts.addAll(formOnePositionAccounts("VIII", "Correspondent banks (net balance counts)", + "FORM1_III_A_SBI_CURRENT", today, officeId)); + + // Reserve accounts the bank holds but has not mapped to a head of the form. They are shown + // because money can be moved into them whatever the filed return counts, and a desk cannot + // act on an account the screen does not list - but they are marked as entering no total, so + // a reader does not add them to a requirement they do not settle. + for (final Map unmapped : formOneUnmappedReserveAccounts(today, officeId, accounts)) { + accounts.add(unmapped); + } + response.put("accounts", accounts); + + return this.gson.toJson(response); + } + + /** + * What is held against section 24 on a date - item XII, built exactly as Part C builds it. + * + *

+ * The cash held over the section 18 requirement, plus VII(c), plus gold, plus the approved + * securities mapped to XII(c). VII(a) and VII(b) are NOT in it: they are deposits placed with + * the State and District Central Co-operative Banks, which the filed return does not admit as + * liquid assets for SLR. + * + *

+ * One definition for every place that reports it. The position screen and the day-by-day sheet + * each carried their own copy, written before Part C was corrected, and went on counting VII(a) + * and VII(b) - so one dialog quoted two different SLR figures, five and a half crore apart, + * depending on which tab was open. + */ + private BigDecimal formOneSlrHeld(final LocalDate date, final BigDecimal crrHeld, final BigDecimal crrRequired, + final Long officeId) { + final BigDecimal excessCash = crrHeld.subtract(crrRequired).max(BigDecimal.ZERO); + return excessCash.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount) + .add(figureFor("FORMA_SLR_GOLD", date, officeId).amount) + .add(figureFor("SLR_INVESTMENTS", date, officeId).amount); + } + + /** + * One ratio's standing - required, held, and the gap, with the gap named rather than signed. + */ + private Map formOneRatioPosition(final BigDecimal required, final BigDecimal held) { + final Map position = new LinkedHashMap<>(); + final BigDecimal gap = held.subtract(required); + position.put("required", required); + position.put("maintained", held); + position.put("surplus", gap.max(BigDecimal.ZERO)); + // Positive: a shortfall is an amount to be covered, and a desk reading "move 4,13,000" does + // not want to re-sign a negative first. + position.put("shortfall", gap.min(BigDecimal.ZERO).negate()); + position.put("met", gap.compareTo(BigDecimal.ZERO) >= 0); + return position; + } + + /** + * Reserve accounts the bank holds that no head of the form picks up. + * + *

+ * A balance with the Reserve Bank that the filed return leaves out of item VI(a) is still an + * account money can be moved into, and a screen that listed only mapped accounts would leave a + * desk unable to use it. They are returned marked - entering no total - rather than silently + * added to what is maintained, which would have the dashboard claim compliance the return does + * not. + * + *

+ * Identified by the GL's own account_usage and classification rather than by code, so a bank + * that names its reserve accounts differently is not left with an empty list. + */ + private List> formOneUnmappedReserveAccounts(final LocalDate onDate, final Long officeId, + final List> alreadyListed) { + final Set listed = new HashSet<>(); + for (final Map account : alreadyListed) { + listed.add(account.get("glAccountId")); + } + + final List> rows = jdbcTemplate.queryForList(""" + SELECT a.id, a.gl_code, a.name + FROM acc_gl_account a + WHERE a.classification_enum = 1 AND a.account_usage = 1 AND a.disabled = false + AND (UPPER(a.name) LIKE '%RESERVE BANK%' OR UPPER(a.name) LIKE '%RBI%') + ORDER BY a.gl_code + """); + + final List> unmapped = new ArrayList<>(); + for (final Map row : rows) { + final Long accountId = ((Number) row.get("id")).longValue(); + if (listed.contains(accountId)) { + continue; + } + final Map account = new LinkedHashMap<>(); + account.put("glAccountId", accountId); + account.put("glCode", row.get("gl_code")); + account.put("name", row.get("name")); + account.put("balance", balanceOf(accountId, onDate, officeId)); + account.put("item", "VI(a)"); + account.put("head", "Reserve Bank of India"); + account.put("configKey", null); + // Shown, but not counted: the filed return does not read this balance, so adding it to + // what is maintained would have the dashboard and the form disagree. + account.put("countsTowardMaintained", false); + unmapped.add(account); + } + return unmapped; + } + + /** + * One GL account's balance on a date, read the way the rest of the return reads balances. + */ + private BigDecimal balanceOf(final Long accountId, final LocalDate onDate, final Long officeId) { + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END), 0) AS balance + 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(onDate))); + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + return (BigDecimal) row.get("balance"); + } + + /** + * The accounts mapped to one head, with what each holds on the date given. + */ + private List> formOnePositionAccounts(final String item, final String head, + final String configKey, final LocalDate onDate, final Long officeId) { + final List> accounts = new ArrayList<>(); + final RoleFigure figure = figureFor(configKey, onDate, officeId); + for (final Map account : figure.accounts) { + final Map row = new LinkedHashMap<>(account); + row.put("item", item); + row.put("head", head); + row.put("configKey", configKey); + // Mapped to a head the form reads, so this balance is part of what is maintained. + row.put("countsTowardMaintained", true); + accounts.add(row); + } + return accounts; + } + + /** + * Moves money between the accounts the cash reserve is held in. + * + *

+ * Its own endpoint rather than the manual journal entry screen, because that screen is turned + * off at the HTTP edge on this installation (see DisabledFeatureApiFilter) - deliberately, so + * that nobody posts a free-hand entry against the ledger. A reserve transfer is not a free-hand + * entry: both sides are reserve accounts the administrator has mapped, the amount is checked + * against what the source actually holds, and the entry is raised in Java like the clearing and + * disbursement flows, so it goes through maker-checker exactly as they do. + * + *

+ * It records the movement; it does not make it. The money reaches the other bank when the + * treasury desk sends the NEFT or RTGS - this is the bank's own record of having done so. + * + *

+ * These are asset accounts, so the one money LEAVES is credited and the one it arrives in is + * debited. The request names them as a desk thinks of them, from and to, and the sides are + * worked out here. + */ + @POST + @Path("transfer") + @Consumes({ MediaType.APPLICATION_JSON }) + public String reserveTransfer(final String apiRequestBodyAsJson) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final JsonObject request = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject(); + final Long fromAccountId = request.get("fromGlAccountId").getAsLong(); + final Long toAccountId = request.get("toGlAccountId").getAsLong(); + final BigDecimal amount = request.get("amount").getAsBigDecimal(); + final Long officeId = request.has("officeId") && !request.get("officeId").isJsonNull() + ? request.get("officeId").getAsLong() + : null; + final String reference = request.has("reference") && !request.get("reference").isJsonNull() + ? request.get("reference").getAsString() + : null; + final String note = request.has("comments") && !request.get("comments").isJsonNull() + ? request.get("comments").getAsString() + : null; + + if (fromAccountId.equals(toAccountId)) { + throw new PlatformDataIntegrityException("error.msg.reserve.transfer.same.account", + "The money cannot be moved to the account it is already in."); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new PlatformDataIntegrityException("error.msg.reserve.transfer.invalid.amount", + "The amount to transfer must be more than nothing."); + } + + // Both sides must be accounts the reserve is actually held in. Checked here rather than + // trusted from the request: an endpoint that moved money between any two GLs on the say-so + // of its caller would be the manual journal entry screen under another name, which is the + // thing this installation turned off. + final LocalDate today = DateUtils.getBusinessLocalDate(); + final Set reserveAccounts = formOneReserveAccountIds(); + if (!reserveAccounts.contains(fromAccountId) || !reserveAccounts.contains(toAccountId)) { + throw new PlatformDataIntegrityException("error.msg.reserve.transfer.not.reserve.account", + "Both accounts must be mapped to a cash reserve head on the RBI Reserve Configuration."); + } + + // An account cannot send more than it holds: the ledger would carry a negative balance on a + // cash or bank account, which is not a position that exists. + final BigDecimal available = balanceOf(fromAccountId, today, officeId); + if (amount.compareTo(available) > 0) { + throw new PlatformDataIntegrityException("error.msg.reserve.transfer.insufficient.balance", + "The account holds " + available.toPlainString() + ", which is less than the amount to transfer."); + } + + final JsonArray debits = new JsonArray(); + final JsonObject debit = new JsonObject(); + debit.addProperty("glAccountId", toAccountId); + debit.addProperty("amount", amount); + debits.add(debit); + + final JsonArray credits = new JsonArray(); + final JsonObject credit = new JsonObject(); + credit.addProperty("glAccountId", fromAccountId); + credit.addProperty("amount", amount); + credits.add(credit); + + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", officeId == null ? 1L : officeId); + payload.addProperty("transactionDate", today.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", "INR"); + payload.addProperty("comments", note == null || note.isBlank() ? "Reserve transfer" : note); + if (reference != null && !reference.isBlank()) { + payload.addProperty("referenceNumber", reference); + } + payload.add("debits", debits); + payload.add("credits", credits); + + final CommandWrapper command = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()) + .build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command); + + final Map response = new LinkedHashMap<>(); + response.put("transactionId", result == null ? null : result.getTransactionId()); + response.put("fromGlAccountId", fromAccountId); + response.put("toGlAccountId", toAccountId); + response.put("amount", amount); + response.put("transactionDate", today.toString()); + return this.gson.toJson(response); + } + + /** + * The GL accounts the cash reserve may be moved between - every account mapped to a head item X + * reads, plus the correspondent balances item VIII counts. + * + *

+ * Read from the configuration rather than listed here, so a bank that opens another current + * account and maps it can move money into it without this endpoint being changed. + */ + private Set formOneReserveAccountIds() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT DISTINCT c.gl_account_id AS id + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE a.disabled = false + AND c.config_key IN ('CASH_IN_HAND', 'FORM1_VI_A_RBI_CURRENT', 'FORM1_VI_B_STATE_COOP_CURRENT', + 'FORM1_VI_C_DCCB_CURRENT', 'FORM1_III_A_SBI_CURRENT') + """); + final Set ids = new HashSet<>(); + for (final Map row : rows) { + ids.add(((Number) row.get("id")).longValue()); + } + // The RBI current account the return leaves unmapped is still somewhere money can be sent, + // and the position screen lists it for that reason. Admitted here on the same terms. + final List> rbi = jdbcTemplate.queryForList(""" + SELECT a.id FROM acc_gl_account a + WHERE a.classification_enum = 1 AND a.account_usage = 1 AND a.disabled = false + AND (UPPER(a.name) LIKE '%RESERVE BANK%' OR UPPER(a.name) LIKE '%RBI%') + """); + for (final Map row : rbi) { + ids.add(((Number) row.get("id")).longValue()); + } + return ids; + } @GET @Path("form1-statutory") public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam, @@ -1252,6 +1665,10 @@ public class RbiReserveReturnApiResource { response.put("partB", formOnePartB(columnDates, baseDates, officeId, crr)); response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr)); + // The working behind item XII(c): what the securities opened at, what moved inside the + // fortnight, and what they closed at. The compliance sheet states one figure; this is it. + response.put("securities", formOneSecurities(columnDates, officeId)); + // 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)); @@ -1301,37 +1718,46 @@ public class RbiReserveReturnApiResource { 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(withFormula(formOneTotal("TOTAL OF I : >>>>>>>>>>>", totalI), "I(a)(i) + I(a)(ii) + I(b)")); 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<>(); + final List>> iiASources = new ArrayList<>(); + final List>> iiBSources = new ArrayList<>(); for (final LocalDate date : dates) { final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", date, officeId); iiA.add(dtl.demandAmount); iiB.add(dtl.timeAmount); + iiASources.add(sourcesOf(dtl, DEMAND)); + iiBSources.add(sourcesOf(dtl, TIME)); } - lines.add(formOneRow("(A) DEMAND LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiA, 1)); - lines.add(formOneRow("(B) TIME LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiB, 1)); + lines.add(withSources(formOneRow("(A) DEMAND LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiA, 1), iiASources)); + lines.add(withSources(formOneRow("(B) TIME LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiB, 1), iiBSources)); final List totalII = sumOf(iiA, iiB); - lines.add(formOneTotal("TOTAL OF II : >>>>>>>>>>>", totalII)); + lines.add(withFormula(formOneTotal("TOTAL OF II : >>>>>>>>>>>", totalII), "II(A) + II(B)")); lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM")); final List iiiA = formOneLine(lines, "(A) TOTAL OF CREDIT BALANCES IN CURRENT ACCOUNT maintained with S.B.I, Subsidiary Banks & corresponding new banks", "FORM1_III_A_SBI_CURRENT", dates, officeId, 1); - lines.add(formOneHeading("(B) TOTAL OF OTHER ASSETS WITH THE BANKING SYSTEM, VIZ.")); - final List iiiBi = formOneLine(lines, - "(i) Balances in all Accounts other than those included in item 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 & any other Assets :", - "FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2); + // The filed form carries the sub-total of III(b) against the (B) heading itself and + // leaves (i), (ii) and (iv) blank beneath it, so the sub-lines are gathered first and + // the heading row is the one that prints the figure. + final List iiiBi = amountsFor("FORM1_III_B_I_OTHER_BALANCES", dates, officeId); + final List iiiBii = amountsFor("FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId); + final List iiiBiv = amountsFor("FORM1_III_B_IV_ADVANCES_BS", dates, officeId); + lines.add(withSources(formOneRow("(B) TOTAL OF OTHER ASSETS WITH THE BANKING SYSTEM, VIZ.", null, + sumOf(iiiBi, iiiBii, iiiBiv), 1), + mergedSources(List.of("FORM1_III_B_I_OTHER_BALANCES", "FORM1_III_B_II_MONEY_AT_CALL_BS", + "FORM1_III_B_IV_ADVANCES_BS"), dates, officeId, null))); + lines.add(formOneHeading("(i) Balances in all Accounts other than those included in item III (a) :")); + lines.add(formOneHeading("(ii) Money at Call and Short notice :")); + lines.add(formOneHeading("(iv) Advances & any other Assets :")); final List totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv); - lines.add(formOneTotal("TOTAL OF III : >>>>>>>>>>", totalIII)); + lines.add(withFormula(formOneTotal("TOTAL OF III : >>>>>>>>>>", totalIII), "III(A) + III(B)")); // 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", @@ -1345,10 +1771,10 @@ public class RbiReserveReturnApiResource { // 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( + lines.add(withFormula(formOneTotal( "IV. a) TOTAL (NET) DEMAND & TIME LIABILITIES FOR THE PURPOSE OF SEC.18 & 24 OF THE ACT = (I-III)+II IF MINUS" + " FIGURE [CURRENT FORTNIGHT]", - iv)); + iv), "(I - III) + II. The net is floored at zero before II is added")); final List ivBase = new ArrayList<>(); final List ivBaseDates = new ArrayList<>(); for (int i = 0; i < dates.size(); i++) { @@ -1365,6 +1791,7 @@ public class RbiReserveReturnApiResource { "IV. b) DEMAND AND TIME LIABILITY OF BASE DATE FOR THE PURPOSE OF SEC.18 & 24", ivBase); ivBaseRow.put("baseDates", ivBaseDates); + withFormula(ivBaseRow, "The same net liabilities, struck on the base date shown in the column heading"); lines.add(ivBaseRow); final List v = formOneLine(lines, "V. CASH ON HAND :", "CASH_IN_HAND", dates, officeId, 0); @@ -1377,7 +1804,7 @@ public class RbiReserveReturnApiResource { final List viC = formOneLine(lines, "(c) Central Co.Op.Bank of the Dist.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(withFormula(formOneTotal("TOTAL OF VI : >>>>>>>>>>", totalVI), "VI(a) + VI(b) + VI(c)")); lines.add(formOneHeading("VII. Balance Of all Other types With :")); final List viiA = formOneLine(lines, "(a) State Co.Op.Bank of the State Concerned :", @@ -1387,7 +1814,7 @@ public class RbiReserveReturnApiResource { final List viiC = formOneLine(lines, "(C) INVESTMENT OF GOVT. SECURITY", "FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1); final List totalVII = sumOf(viiA, viiB, viiC); - lines.add(formOneTotal("TOTAL OF VII : >>>>>>>>>>", totalVII)); + lines.add(withFormula(formOneTotal("TOTAL OF VII : >>>>>>>>>>", totalVII), "VII(a) + VII(b) + VII(c)")); // 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. @@ -1395,7 +1822,7 @@ public class RbiReserveReturnApiResource { 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 i.e.excess of III (a) over I (a) (i) :", viii)); + lines.add(withFormula(formOneTotal("VIII. Net balance in current accounts i.e.excess of III (a) over I (a) (i) :", viii), "III(A) - I(a)(i), nil if negative")); // 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. @@ -1432,9 +1859,9 @@ public class RbiReserveReturnApiResource { // 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 == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE", - required)); - lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED := V+VI+VIII", maintained)); + lines.add(withFormula(formOneTotal("IX. " + (crr == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE", + required), "CRR rate x IV(b), the net liabilities on the base date")); + lines.add(withFormula(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED := V+VI+VIII", maintained), "V + VI + VIII")); final Map part = new LinkedHashMap<>(); part.put("title", "PART B : COMPLIANCE WITH SECTION 18. (Not applicable to scheduled state co-op. Bank)"); @@ -1478,14 +1905,21 @@ public class RbiReserveReturnApiResource { } // Charged on IV(b) like the cash reserve above, and named the same way. - lines.add(formOneTotal("XI. " + (slr == null ? "SLR rate not set" : slr.percentage + " percent") + " (or a higher specified percentage) of IV as on the base date", - required)); + lines.add(withFormula(formOneTotal("XI. " + (slr == null ? "SLR rate not set" : slr.percentage + " percent") + " (or a higher specified percentage) of IV as on the base date", + required), "SLR rate x IV(b), the net liabilities on the base date")); lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED :")); - lines.add(formOneRow("(a) Cash and other balances maintained in India i.e. X-IX+VII :", null, cashAndOther, 1)); - lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1)); - lines.add(formOneRow("(c) Unencombered approved Securites $$", "SLR_INVESTMENTS", securities, 1)); + lines.add(withSources(withFormula( + formOneRow("(a) Cash and other balances maintained in India i.e. X-IX+VII :", null, cashAndOther, 1), + "Excess of X over IX (cash reserve held above the section 18 requirement, nil if short) + VII(c) " + + "government securities"), + mergedSources(List.of("FORM1_VII_C_GOVT_SECURITIES"), dates, officeId, null))); + lines.add(withSources(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1), + mergedSources(List.of("FORMA_SLR_GOLD"), dates, officeId, null))); + lines.add(withSources(formOneRow("(c) Unencombered approved Securites $$", "SLR_INVESTMENTS", securities, 1), + mergedSources(List.of("SLR_INVESTMENTS"), dates, officeId, null))); final List totalXII = sumOf(cashAndOther, gold, securities); - lines.add(formOneTotal("Total of XII.....................................", totalXII)); + lines.add(withFormula(formOneTotal("Total of XII.....................................", totalXII), + "XII(a) + XII(b) + XII(c)")); final Map part = new LinkedHashMap<>(); part.put("title", "PART C : COMPLIANCE WITH SECTION 24 (Not applicable to scheduled state co-op. banks)"); @@ -1496,6 +1930,153 @@ public class RbiReserveReturnApiResource { return part; } + + /** + * Part C.XII.c - the SLR securities the return is backed by, with how they moved. + * + *

+ * The compliance sheet states one figure for securities held; this is the working behind it. + * Each column reports a fortnight, and within a fortnight a holding is opened, added to, sold + * out of and closed, so the closing figure alone cannot be checked - an inspector asks what + * moved, not only what is left. + * + *

+ * The opening balance is the holding at the close of the fortnight before, and the additions + * and deductions are the debits and credits posted inside the fortnight itself. Both are read + * off the journal rather than stated, so the closing balance is arithmetic a reader can follow + * from the ledger rather than a figure this return asserts. + * + *

+ * Face value and book value are both reported at the ledger's carrying amount, and depreciation + * as nil: a securities GL holds one figure, already net of any provision made against it. + * Reporting depreciation off the investment depreciation reserve would deduct it twice, since + * that reserve is not held security by security. + */ + private Map formOneSecurities(final List dates, final Long officeId) { + final Map securities = new LinkedHashMap<>(); + securities.put("title", "Part C.XII.c - SLR securities"); + + // Government securities are Part I; approved securities that are not Government paper - + // land mortgage debentures, trustee securities - are Part II. Gold is NOT either of them: + // the form reports it on its own line at item XII(b), and counting it here would have the + // sheet total to more than the securities line it is the working for. + final List> sections = new ArrayList<>(); + sections.add(formOneSecuritySection("Part - I (Government Securities)", "FORM1_VII_C_GOVT_SECURITIES", dates, + officeId)); + sections.add(formOneSecuritySection("Part - II (Other Approved Securities)", "FORM1_XII_C_OTHER_APPROVED", + dates, officeId)); + securities.put("sections", sections); + + // The two parts together, which is the figure Part C item XII(c) reports. + final List closing = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + BigDecimal total = BigDecimal.ZERO; + for (final Map section : sections) { + @SuppressWarnings("unchecked") + final List sectionClosing = (List) section.get("closing"); + total = total.add(sectionClosing.get(i)); + } + closing.add(total); + } + securities.put("closing", closing); + return securities; + } + + /** + * One part of the securities sheet - what it opened at, what moved, and what it closed at. + */ + private Map formOneSecuritySection(final String title, final String configKey, + final List dates, final Long officeId) { + final Map section = new LinkedHashMap<>(); + section.put("title", title); + section.put("configKey", configKey); + + final List opening = new ArrayList<>(); + final List additions = new ArrayList<>(); + final List deductions = new ArrayList<>(); + final List closing = new ArrayList<>(); + + for (int i = 0; i < dates.size(); i++) { + final LocalDate close = dates.get(i); + // The fortnight a column reports runs from the close before it. For the first column + // that close is one fortnight earlier, so the opening balance is a real position + // rather than a zero that would report the whole holding as bought this period. + final LocalDate openedOn = i == 0 ? previousFortnightClose(close) : dates.get(i - 1); + + final BigDecimal[] moved = formOneSecurityMovement(configKey, openedOn, close, officeId); + opening.add(figureFor(configKey, openedOn, officeId).amount); + additions.add(moved[0]); + deductions.add(moved[1]); + closing.add(figureFor(configKey, close, officeId).amount); + } + + section.put("opening", opening); + section.put("additions", additions); + section.put("deductions", deductions); + section.put("closing", closing); + return section; + } + + /** + * What was bought and what was sold inside a fortnight, read off the journal. + * + *

+ * Additions first and deductions second, both positive: the sheet carries them in separate + * columns, and a filer copying between the two should not have to re-sign anything. + */ + private BigDecimal[] formOneSecurityMovement(final String configKey, final LocalDate after, final LocalDate upto, + final Long officeId) { + final List> accounts = jdbcTemplate.queryForList(""" + SELECT a.id, c.weight_percentage + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE c.config_key = ? AND a.disabled = false + """, configKey); + + BigDecimal additions = BigDecimal.ZERO; + BigDecimal deductions = BigDecimal.ZERO; + for (final Map account : accounts) { + final Long accountId = ((Number) account.get("id")).longValue(); + final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED + : (BigDecimal) account.get("weight_percentage"); + + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS bought, + COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS sold + FROM acc_gl_journal_entry + WHERE account_id = ? AND entry_date > ? 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(after), java.sql.Date.valueOf(upto))); + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + final BigDecimal bought = (BigDecimal) row.get("bought"); + final BigDecimal sold = (BigDecimal) row.get("sold"); + final BigDecimal share = weight.divide(HUNDRED, 6, RoundingMode.HALF_UP); + additions = additions.add(bought.multiply(share).setScale(2, RoundingMode.HALF_UP)); + deductions = deductions.add(sold.multiply(share).setScale(2, RoundingMode.HALF_UP)); + } + return new BigDecimal[] { additions, deductions }; + } + + /** + * The fortnight close before the one given - the 15th, or the month end going back. A column's + * opening balance is read there. + */ + private LocalDate previousFortnightClose(final LocalDate close) { + if (close.getDayOfMonth() > 15) { + return close.withDayOfMonth(15); + } + final LocalDate previousMonth = close.minusMonths(1); + return previousMonth.withDayOfMonth(previousMonth.lengthOfMonth()); + } + /** * Every day of the period the form covers, with what each ratio required that day and what was * held against it. @@ -1529,21 +2110,22 @@ public class RbiReserveReturnApiResource { 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); + + // The rate IN FORCE THAT DAY, not the one in force on the reporting date. Rates are + // effective-dated, so a rate changed mid-month leaves the days before it charged at the + // old rate and the days after at the new one. Charging the whole period at the closing + // rate would restate days already settled - and on a return that is read to see whether + // each day was met, every day before the change would be measured against a + // requirement that did not exist yet. + final Rate crrThatDay = rateInForce("CRR", day); + final Rate slrThatDay = rateInForce("SLR", day); + final BigDecimal crrRequired = crrThatDay == null ? BigDecimal.ZERO + : base.multiply(crrThatDay.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + final BigDecimal slrRequired = slrThatDay == null ? BigDecimal.ZERO + : base.multiply(slrThatDay.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 slrHeld = formOneSlrHeld(day, crrHeld, crrRequired, officeId); final BigDecimal crrSurplus = crrHeld.subtract(crrRequired); final BigDecimal slrSurplus = slrHeld.subtract(slrRequired); @@ -1577,6 +2159,14 @@ public class RbiReserveReturnApiResource { // 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)); + // The rate each requirement was struck at, and the date it came into force. Stated per + // day because a rate changed mid-period leaves the sheet carrying two of them, and a + // reader checking a day's requirement against the rate shown at the top of the screen + // would find arithmetic that does not work for the days on the other side of the change. + row.put("crrPercentage", crrThatDay == null ? null : crrThatDay.percentage); + row.put("crrEffectiveFrom", crrThatDay == null ? null : crrThatDay.effectiveFrom.toString()); + row.put("slrPercentage", slrThatDay == null ? null : slrThatDay.percentage); + row.put("slrEffectiveFrom", slrThatDay == null ? null : slrThatDay.effectiveFrom.toString()); row.put("crrRequired", crrRequired); row.put("crrMaintained", crrHeld); row.put("crrSurplus", crrSurplus); @@ -1723,18 +2313,88 @@ public class RbiReserveReturnApiResource { return row; } + /** + * The accounts behind one column of a printed line - what the screen lists when a reader + * rests on a figure to see where it came from. + * + * @param liabilityClass + * when given, only the slices of that class: the one DTL head prints as two lines, + * demand and time, and each must show only the accounts it was made of. + */ + private static List> sourcesOf(final RoleFigure figure, final String liabilityClass) { + final List> out = new ArrayList<>(); + for (final Map account : figure.accounts) { + if (liabilityClass != null && !liabilityClass.equals(account.get("liabilityClass"))) { + continue; + } + final Map source = new LinkedHashMap<>(); + source.put("glCode", account.get("glCode")); + source.put("name", account.get("name")); + source.put("accountBalance", account.get("accountBalance")); + source.put("weightPercentage", account.get("weightPercentage")); + source.put("amount", account.get("balance")); + source.put("excluded", account.get("excluded")); + out.add(source); + } + return out; + } + + /** Sources for a line made of several heads, one list per column. */ + private List>> mergedSources(final List configKeys, final List dates, + final Long officeId, final String liabilityClass) { + final List>> columns = new ArrayList<>(); + for (final LocalDate date : dates) { + final List> column = new ArrayList<>(); + for (final String key : configKeys) { + column.addAll(sourcesOf(figureFor(key, date, officeId), liabilityClass)); + } + columns.add(column); + } + return columns; + } + + private static Map withSources(final Map row, + final List>> sources) { + row.put("sources", sources); + return row; + } + + /** How a figure with no account of its own is worked out, in words, for the same popup. */ + private static Map withFormula(final Map row, final String formula) { + row.put("formula", formula); + return row; + } + /** Reads one head across every column and appends its row. */ + /** + * The amounts a role carries across the reported dates, without adding a row for them. + * + * Used where the filed form prints a sub-total against a heading and leaves the lines + * beneath it blank: the figures are still needed to build that total and to carry into + * item IV, but each one must not appear as a row of its own. + */ + private List amountsFor(final String configKey, final List dates, final Long officeId) { + final List values = new ArrayList<>(); + for (final LocalDate date : dates) { + values.add(figureFor(configKey, date, officeId).amount); + } + return values; + } + 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<>(); + final List>> sources = new ArrayList<>(); boolean configured = false; for (final LocalDate date : dates) { final RoleFigure figure = figureFor(configKey, date, officeId); configured |= figure.configured; values.add(figure.amount); + sources.add(sourcesOf(figure, null)); } final Map row = formOneRow(label, configKey, values, indent); row.put("configured", configured); + withSources(row, sources); lines.add(row); return values; } @@ -1821,6 +2481,10 @@ public class RbiReserveReturnApiResource { // with nothing behind it. response.put("partA", formOnePartA(columnDates, baseDates, officeId, crr, slr)); response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr)); + + // The working behind item XII(c): what the securities opened at, what moved inside the + // fortnight, and what they closed at. The compliance sheet states one figure; this is it. + response.put("securities", formOneSecurities(columnDates, officeId)); // 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)); @@ -2280,10 +2944,29 @@ public class RbiReserveReturnApiResource { } response.put("caveats", caveats); response.put("computable", true); + // Item 12 as the bank filed it, where one is on record. Sent with the return, not left for + // the screen to look up, so every user who may read the return sees the same figure rather + // than one that depends on also holding the permission to read Global Configurations. + final BigDecimal filedLoss = filedBalanceOfLoss(asOn); + if (filedLoss != null) { + response.put("filedBalanceOfLoss", filedLoss); + } return gson.toJson(response); } + /** + * The Balance of Loss the bank filed for a Form IX date, in rupees thousands, from the global + * configuration {@code form9-balance-of-loss-}. No GL carries that figure, so it is a + * stated number kept as data, where Admin can see and change it. + */ + private BigDecimal filedBalanceOfLoss(final LocalDate asOn) { + final List rows = jdbcTemplate.queryForList( + "SELECT value FROM c_configuration WHERE name = ? AND enabled = true AND value IS NOT NULL", + Integer.class, "form9-balance-of-loss-" + asOn); + return rows.isEmpty() ? null : BigDecimal.valueOf(rows.get(0)); + } + /** * Rupees to rupees-thousands, rounded half-up. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountChargesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountChargesApiResource.java index 5367ee7c4..aeb34e0f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountChargesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountChargesApiResource.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.savings.api; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -35,21 +37,18 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.UriInfo; -import java.util.Collection; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; -import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; -import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; -import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService; import org.springframework.stereotype.Component; @Path("/v1/currentaccounts/{accountId}/charges") @@ -58,11 +57,12 @@ import org.springframework.stereotype.Component; @RequiredArgsConstructor public class CurrentAccountChargesApiResource { + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + private final PlatformSecurityContext context; private final DefaultToApiJsonSerializer toApiJsonSerializer; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - private final ApiRequestParameterHelper apiRequestParameterHelper; - private final SavingsAccountChargeReadPlatformService savingsAccountChargeReadPlatformService; + private final CurrentAccountReadPlatformService currentAccountReadPlatformService; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -73,11 +73,19 @@ public class CurrentAccountChargesApiResource { @DefaultValue("all") @QueryParam("chargeStatus") final String chargeStatus, @Context final UriInfo uriInfo) { context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_CHARGE_RESOURCE_NAME); - final Collection charges = savingsAccountChargeReadPlatformService - .retrieveSavingsAccountCharges(accountId, chargeStatus); - final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); - return toApiJsonSerializer.serialize(settings, charges, - SavingsApiSetConstants.SAVINGS_ACCOUNT_CHARGES_RESPONSE_DATA_PARAMETERS); + // m_current_account_charge, not the savings charge table: the same id names a different account there. + return GSON.toJson(currentAccountReadPlatformService.retrieveCharges(accountId, chargeStatus)); + } + + @GET + @Path("template") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Charge options for adding a charge to a current account") + @ApiResponse(responseCode = "200", description = "OK") + public String retrieveTemplate(@PathParam("accountId") final Long accountId) { + context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_CHARGE_RESOURCE_NAME); + return GSON.toJson(currentAccountReadPlatformService.retrieveChargeTemplate(accountId)); } @GET @@ -90,11 +98,7 @@ public class CurrentAccountChargesApiResource { @PathParam("chargeId") final Long chargeId, @Context final UriInfo uriInfo) { context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_CHARGE_RESOURCE_NAME); - final SavingsAccountChargeData charge = savingsAccountChargeReadPlatformService.retrieveSavingsAccountChargeDetails(chargeId, - accountId); - final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); - return toApiJsonSerializer.serialize(settings, charge, - SavingsApiSetConstants.SAVINGS_ACCOUNT_CHARGES_RESPONSE_DATA_PARAMETERS); + return GSON.toJson(currentAccountReadPlatformService.retrieveCharge(accountId, chargeId)); } @POST diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java index f084e3e97..ac3aa46ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java @@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; @@ -62,6 +63,13 @@ import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiReso import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse; +import org.apache.fineract.portfolio.note.api.NotesApiResource; +import org.apache.fineract.portfolio.note.data.NoteCreateRequest; +import org.apache.fineract.portfolio.note.data.NoteCreateResponse; +import org.apache.fineract.portfolio.note.data.NoteData; +import org.apache.fineract.portfolio.note.data.NoteDeleteResponse; +import org.apache.fineract.portfolio.note.data.NoteUpdateRequest; +import org.apache.fineract.portfolio.note.data.NoteUpdateResponse; import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.SqlValidator; @@ -100,6 +108,7 @@ public class CurrentAccountsApiResource { private final FromJsonHelper fromApiJsonHelper; private final DefaultToApiJsonSerializer pendingEditApiJsonSerializer; private final DocumentApiResource documentApiResource; + private final NotesApiResource notesApiResource; /** Matches the {@code entityType} current-account document requests are stored/looked up under. */ private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts"; @@ -406,4 +415,56 @@ public class CurrentAccountsApiResource { return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId); } + // ── Notes ──────────────────────────────────────────────────────────────── + // Same reason as Documents above: the generic /v1/{resourceType}/{resourceId}/notes resource is never + // reached for current accounts, so these delegate to NotesApiResource (resource type "currentaccounts"). + + @GET + @Path("{accountId}/notes") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List notes on a current account") + @ApiResponse(responseCode = "200", description = "OK") + public List retrieveNotes(@PathParam("accountId") final Long accountId) { + return notesApiResource.retrieveNotesByResource(DOCUMENT_ENTITY_TYPE, accountId); + } + + @GET + @Path("{accountId}/notes/{noteId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a note on a current account") + @ApiResponse(responseCode = "200", description = "OK") + public NoteData retrieveNote(@PathParam("accountId") final Long accountId, @PathParam("noteId") final Long noteId) { + return notesApiResource.retrieveNote(DOCUMENT_ENTITY_TYPE, accountId, noteId); + } + + @POST + @Path("{accountId}/notes") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Add a note to a current account") + @ApiResponse(responseCode = "200", description = "OK") + public NoteCreateResponse addNote(@PathParam("accountId") final Long accountId, @Valid final NoteCreateRequest request) { + return notesApiResource.addNewNote(DOCUMENT_ENTITY_TYPE, accountId, request); + } + + @PUT + @Path("{accountId}/notes/{noteId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Update a note on a current account") + @ApiResponse(responseCode = "200", description = "OK") + public NoteUpdateResponse updateNote(@PathParam("accountId") final Long accountId, @PathParam("noteId") final Long noteId, + @Valid final NoteUpdateRequest request) { + return notesApiResource.updateNote(DOCUMENT_ENTITY_TYPE, accountId, noteId, request); + } + + @DELETE + @Path("{accountId}/notes/{noteId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Delete a note on a current account") + @ApiResponse(responseCode = "200", description = "OK") + public NoteDeleteResponse deleteNote(@PathParam("accountId") final Long accountId, @PathParam("noteId") final Long noteId) { + return notesApiResource.deleteNote(DOCUMENT_ENTITY_TYPE, accountId, noteId); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RdInterestRateSaveRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RdInterestRateSaveRequest.java index 2674c73ee..e9f69c308 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RdInterestRateSaveRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RdInterestRateSaveRequest.java @@ -49,5 +49,6 @@ public class RdInterestRateSaveRequest { private Integer fromPeriod; private Integer toPeriod; private BigDecimal annualInterestRate; + private BigDecimal seniorCitizenRate; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java index 753d294d2..ce45a8831 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java @@ -23,6 +23,7 @@ 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.QueryParam; import jakarta.ws.rs.core.MediaType; import java.time.LocalDate; import java.util.LinkedHashMap; @@ -71,6 +72,7 @@ public class RecurringDepositDashboardApiResource { JOIN m_client c ON c.id = sa.client_id JOIN m_office o ON o.id = c.office_id WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ? + AND (CAST(? AS BIGINT) IS NULL OR sa.id = CAST(? AS BIGINT)) ), paid AS ( SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid, @@ -178,7 +180,7 @@ public class RecurringDepositDashboardApiResource { @GET @Produces(MediaType.APPLICATION_JSON) - public String retrieve() { + public String retrieve(@QueryParam("accountId") final Long accountId) { this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME); final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%"; @@ -187,7 +189,7 @@ public class RecurringDepositDashboardApiResource { final LocalDate monthStart = today.withDayOfMonth(1); final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth()); - final List> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy, + final List> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy, accountId, accountId, monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd); final Map closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE, RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java index 58045cb1a..2975444f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java @@ -27,4 +27,5 @@ public class RdInterestRateSlabData { private Integer fromPeriod; private Integer toPeriod; private BigDecimal annualInterestRate; + private BigDecimal seniorCitizenRate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateSlabHistory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateSlabHistory.java index daa1e5ecd..00870348c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateSlabHistory.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateSlabHistory.java @@ -52,8 +52,13 @@ public class RdBaseInterestRateSlabHistory { @Column(name = "annual_interest_rate") private BigDecimal annualInterestRate; + /** The rate a senior citizen earns on this slab; zero means the slab carries no senior premium. */ + @Column(name = "senior_citizen_rate") + private BigDecimal seniorCitizenRate; + public static RdBaseInterestRateSlabHistory create(Long effectiveId, Integer srNo, - String description, Integer periodTypeEnum, Integer fromPeriod, Integer toPeriod, BigDecimal annualInterestRate) { + String description, Integer periodTypeEnum, Integer fromPeriod, Integer toPeriod, BigDecimal annualInterestRate, + BigDecimal seniorCitizenRate) { RdBaseInterestRateSlabHistory s = new RdBaseInterestRateSlabHistory(); s.effectiveId = effectiveId; s.srNo = srNo; @@ -62,6 +67,7 @@ public class RdBaseInterestRateSlabHistory { s.fromPeriod = fromPeriod; s.toPeriod = toPeriod; s.annualInterestRate = annualInterestRate; + s.seniorCitizenRate = seniorCitizenRate != null ? seniorCitizenRate : BigDecimal.ZERO; return s; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/AddCurrentAccountChargeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/AddCurrentAccountChargeCommandHandler.java index a940f3806..4fe6bc0da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/AddCurrentAccountChargeCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/AddCurrentAccountChargeCommandHandler.java @@ -22,7 +22,7 @@ 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.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,16 +31,16 @@ import org.springframework.transaction.annotation.Transactional; @CommandType(entity = "CURRENTACCOUNTCHARGE", action = "CREATE") public class AddCurrentAccountChargeCommandHandler implements NewCommandSourceHandler { - private final SavingsAccountWritePlatformService writePlatformService; + private final CurrentAccountChargeWritePlatformService writePlatformService; @Autowired - public AddCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) { + public AddCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) { this.writePlatformService = writePlatformService; } @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { - return this.writePlatformService.addSavingsAccountCharge(command); + return this.writePlatformService.addCharge(command.getSavingsId(), command); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/DeleteCurrentAccountChargeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/DeleteCurrentAccountChargeCommandHandler.java index 03152e915..5e6fcd8eb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/DeleteCurrentAccountChargeCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/DeleteCurrentAccountChargeCommandHandler.java @@ -22,7 +22,7 @@ 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.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,16 +31,16 @@ import org.springframework.transaction.annotation.Transactional; @CommandType(entity = "CURRENTACCOUNTCHARGE", action = "DELETE") public class DeleteCurrentAccountChargeCommandHandler implements NewCommandSourceHandler { - private final SavingsAccountWritePlatformService writePlatformService; + private final CurrentAccountChargeWritePlatformService writePlatformService; @Autowired - public DeleteCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) { + public DeleteCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) { this.writePlatformService = writePlatformService; } @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { - return this.writePlatformService.deleteSavingsAccountCharge(command.getSavingsId(), command.entityId(), command); + return this.writePlatformService.deleteCharge(command.getSavingsId(), command.entityId()); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/InactivateCurrentAccountChargeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/InactivateCurrentAccountChargeCommandHandler.java index c3a0209e5..f3040cf0c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/InactivateCurrentAccountChargeCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/InactivateCurrentAccountChargeCommandHandler.java @@ -22,7 +22,7 @@ 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.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,10 +31,10 @@ import org.springframework.transaction.annotation.Transactional; @CommandType(entity = "CURRENTACCOUNTCHARGE", action = "INACTIVATE") public class InactivateCurrentAccountChargeCommandHandler implements NewCommandSourceHandler { - private final SavingsAccountWritePlatformService writePlatformService; + private final CurrentAccountChargeWritePlatformService writePlatformService; @Autowired - public InactivateCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) { + public InactivateCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) { this.writePlatformService = writePlatformService; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/PayCurrentAccountChargeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/PayCurrentAccountChargeCommandHandler.java index 3582b9b66..a230eb723 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/PayCurrentAccountChargeCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/PayCurrentAccountChargeCommandHandler.java @@ -22,7 +22,7 @@ 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.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,10 +31,10 @@ import org.springframework.transaction.annotation.Transactional; @CommandType(entity = "CURRENTACCOUNTCHARGE", action = "PAY") public class PayCurrentAccountChargeCommandHandler implements NewCommandSourceHandler { - private final SavingsAccountWritePlatformService writePlatformService; + private final CurrentAccountChargeWritePlatformService writePlatformService; @Autowired - public PayCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) { + public PayCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) { this.writePlatformService = writePlatformService; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/UpdateCurrentAccountChargeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/UpdateCurrentAccountChargeCommandHandler.java index ed2e59f5d..0e3a283dc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/UpdateCurrentAccountChargeCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/UpdateCurrentAccountChargeCommandHandler.java @@ -22,7 +22,7 @@ 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.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,16 +31,16 @@ import org.springframework.transaction.annotation.Transactional; @CommandType(entity = "CURRENTACCOUNTCHARGE", action = "UPDATE") public class UpdateCurrentAccountChargeCommandHandler implements NewCommandSourceHandler { - private final SavingsAccountWritePlatformService writePlatformService; + private final CurrentAccountChargeWritePlatformService writePlatformService; @Autowired - public UpdateCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) { + public UpdateCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) { this.writePlatformService = writePlatformService; } @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { - return this.writePlatformService.updateSavingsAccountCharge(command); + return this.writePlatformService.updateCharge(command.getSavingsId(), command.entityId(), command); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/WaiveCurrentAccountChargeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/WaiveCurrentAccountChargeCommandHandler.java index d9c704273..e82f86b0d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/WaiveCurrentAccountChargeCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/current/WaiveCurrentAccountChargeCommandHandler.java @@ -22,7 +22,7 @@ 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.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,10 +31,10 @@ import org.springframework.transaction.annotation.Transactional; @CommandType(entity = "CURRENTACCOUNTCHARGE", action = "WAIVE") public class WaiveCurrentAccountChargeCommandHandler implements NewCommandSourceHandler { - private final SavingsAccountWritePlatformService writePlatformService; + private final CurrentAccountChargeWritePlatformService writePlatformService; @Autowired - public WaiveCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) { + public WaiveCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) { this.writePlatformService = writePlatformService; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchAccessFilter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchAccessFilter.java new file mode 100644 index 000000000..118410cc7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchAccessFilter.java @@ -0,0 +1,106 @@ +/** + * 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.security; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Keeps a branch user inside their own branch's savings, fixed deposit, recurring deposit and current accounts. + * + *

+ * Those four API families only checked that the caller holds the read/write permission for the resource type, never + * whose account it is, so a branch user could open or transact on another branch's account just by naming its id. This + * filter runs on every {@code /v1//{numericId}...} request (reads, commands, transactions, charges, documents, + * notes) and applies the same office-hierarchy rule the client and group write services use + * ({@link PlatformSecurityContext#validateAccessRights}): the account's office must sit at or below the caller's + * office. Head Office is the root, so it still reaches every branch. + * + *

+ * It only guards HTTP requests. Scheduled jobs (maturity, interest posting, auto-renewal) call the services directly + * and have no logged-in user, so they are untouched. + * + *

+ * An account that does not exist is left alone so the resource returns its own not-found; an account that exists in + * another branch gets the same not-found answer ({@link AccountNotVisibleException}). + */ +@Provider +@Component +@Priority(Priorities.AUTHORIZATION) +@RequiredArgsConstructor +public class AccountBranchAccessFilter implements ContainerRequestFilter { + + /** {@code v1//[/anything]}. Non-numeric segments (dormant, gsim, dashboard...) never match. */ + private static final Pattern ACCOUNT_PATH = Pattern + .compile("^/?v1/(fixeddepositaccounts|recurringdepositaccounts|savingsaccounts|currentaccounts)/(\\d+)(?:/.*)?$"); + + /** + * FD, RD and savings all live in m_savings_account. The office is the branch the account is held at + * (sa.office_id) when that was set, otherwise the client's, or the group's for a group account. + */ + private static final String SAVINGS_FAMILY_HIERARCHY_SQL = "SELECT o.hierarchy FROM m_savings_account sa " + + "LEFT JOIN m_client c ON c.id = sa.client_id LEFT JOIN m_group g ON g.id = sa.group_id " + + "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?"; + + /** A current account carries its own office_id; fall back to the client's if it was left empty. */ + private static final String CURRENT_ACCOUNT_HIERARCHY_SQL = "SELECT o.hierarchy FROM m_current_account ca " + + "LEFT JOIN m_client c ON c.id = ca.client_id JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) " + + "WHERE ca.id = ?"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + + @Override + public void filter(final ContainerRequestContext requestContext) throws IOException { + final Matcher matcher = ACCOUNT_PATH.matcher(requestContext.getUriInfo().getPath()); + if (!matcher.matches()) { + return; + } + // No resolved user: authentication has already rejected the request or it is not ours to judge. + if (this.context.getAuthenticatedUserIfPresent() == null) { + return; + } + + final boolean currentAccount = "currentaccounts".equals(matcher.group(1)); + final Long accountId = Long.valueOf(matcher.group(2)); + final String sql = currentAccount ? CURRENT_ACCOUNT_HIERARCHY_SQL : SAVINGS_FAMILY_HIERARCHY_SQL; + + final String hierarchy = this.jdbcTemplate.query(sql, (rs, rowNum) -> rs.getString(1), accountId).stream().findFirst() + .orElse(null); + if (hierarchy == null) { + return; + } + try { + this.context.validateAccessRights(hierarchy); + } catch (final NoAuthorizationException e) { + throw new AccountNotVisibleException(accountId); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchService.java new file mode 100644 index 000000000..edd0aeef3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountBranchService.java @@ -0,0 +1,159 @@ +/** + * 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.security; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +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.exception.ClientNotActiveException; +import org.apache.fineract.portfolio.savings.data.SavingsAccountConstant; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * One customer, one CIF, accounts at more than one branch. + * + *

+ * A customer is registered at a home branch, and an account normally belongs to that branch. To open an account at + * another branch the customer first has to be linked to it ({@link #linkClientToCallerBranch}), which records + * who introduced the customer to the branch and lets that branch see them. The account is then opened with an + * {@code accountOfficeId} ({@link #applyAccountOffice}), which becomes the branch of its transactions and journal + * entries. Without an {@code accountOfficeId} nothing here changes anything: the account stays at the client's branch. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AccountBranchService { + + private final PlatformSecurityContext context; + private final OfficeRepositoryWrapper officeRepository; + private final ClientRepositoryWrapper clientRepository; + private final JdbcTemplate jdbcTemplate; + + /** + * The branch an account opened for this client is held at, or null when it is simply the client's own branch. + * + *

+ * Which branch: the one the request names, else - when the client belongs to a branch the caller is not part of + * (a customer of Head Office being served at Bhopal) - the caller's own branch, so an account can never be + * silently booked in the home branch's books by a branch that merely served the customer. A user can only open + * an account at their own branch or one below it, and for a customer of another branch only once that customer + * has been linked to the branch. + */ + public Office resolveAccountOffice(final Client client, final Long requestedOfficeId) { + final Office callerOffice = this.context.authenticatedUser().getOffice(); + Long targetOfficeId = requestedOfficeId; + if (targetOfficeId == null) { + if (client.getOffice().getHierarchy().startsWith(callerOffice.getHierarchy())) { + return null; + } + targetOfficeId = callerOffice.getId(); + } + + final Office office = this.officeRepository.findOneWithNotFoundDetection(targetOfficeId); + this.context.validateAccessRights(office.getHierarchy()); + + if (office.getId().equals(client.getOffice().getId())) { + return null; + } + if (!isLinked(client.getId(), office.getId())) { + throw new PlatformDataIntegrityException("error.msg.savings.account.branch.not.linked", + "This customer is not linked to " + office.getName() + ". Link the customer to the branch before opening an account there.", + SavingsAccountConstant.accountOfficeIdParamName, targetOfficeId); + } + return office; + } + + /** + * Opens a newly assembled account at the branch {@link #resolveAccountOffice} picks. Leaves it at the client's + * branch in the ordinary case, so accounts opened the usual way are unchanged. + */ + public void applyAccountOffice(final SavingsAccount account, final JsonCommand command) { + final Long requestedOfficeId = command.longValueOfParameterNamed(SavingsAccountConstant.accountOfficeIdParamName); + final Client client = account.getClient(); + if (client == null) { + if (requestedOfficeId != null) { + throw new PlatformDataIntegrityException("error.msg.savings.account.branch.client.only", + "A branch can only be chosen for a client's account, not a group's.", + SavingsAccountConstant.accountOfficeIdParamName, requestedOfficeId); + } + return; + } + account.setAccountOffice(resolveAccountOffice(client, requestedOfficeId)); + } + + /** + * Open (submitted, approved, active, ...) accounts a client already holds in one product at one branch. + * + *

+ * The rule that a client may hold one account per product used to be counted across the whole client. A customer + * of one branch who opens the same product at another is a different account in a different branch's books, so + * the count is per branch: it uses the account's own branch, falling back to the client's. + */ + public long countOpenAccountsAtBranch(final Long clientId, final Long productId, final Long officeId) { + final Long count = this.jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM m_savings_account sa JOIN m_client c ON c.id = sa.client_id " + + "WHERE sa.client_id = ? AND sa.product_id = ? AND sa.status_enum IN (100, 200, 300, 303, 304) " + + "AND COALESCE(sa.office_id, c.office_id) = ?", + Long.class, clientId, productId, officeId); + return count == null ? 0L : count; + } + + /** + * Introduces a customer of another branch to the caller's branch, so it can see them and open an account for them. + * Idempotent. Returns false when there is nothing to do because the customer is already inside the caller's + * hierarchy (their own branch's customer, or any customer to Head Office). + */ + @Transactional + public boolean linkClientToCallerBranch(final Long clientId) { + final AppUser user = this.context.authenticatedUser(); + final Office callerOffice = user.getOffice(); + final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId); + if (client.isNotActive()) { + throw new ClientNotActiveException(clientId); + } + if (client.getOffice().getHierarchy().startsWith(callerOffice.getHierarchy())) { + return false; + } + if (isLinked(clientId, callerOffice.getId())) { + return false; + } + this.jdbcTemplate.update("INSERT INTO m_client_branch_link (client_id, office_id, linked_by, linked_on) VALUES (?, ?, ?, ?)", clientId, + callerOffice.getId(), user.getId(), DateUtils.getAuditLocalDateTime()); + // Who introduced which customer to which branch: the trail Head Office reads to see where a customer is banked. + log.info("client {} linked to branch {} by user {}", clientId, callerOffice.getId(), user.getUsername()); + return true; + } + + public boolean isLinked(final Long clientId, final Long officeId) { + final Integer count = this.jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM m_client_branch_link WHERE client_id = ? AND office_id = ?", Integer.class, clientId, officeId); + return count != null && count > 0; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountNotVisibleException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountNotVisibleException.java new file mode 100644 index 000000000..b977fca3f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/security/AccountNotVisibleException.java @@ -0,0 +1,33 @@ +/** + * 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.security; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +/** + * Raised when a branch user names an account that belongs to another branch. It is deliberately a "not found" rather + * than a "forbidden": a 403 would confirm that the account exists, which is itself information a branch should not get + * about another branch's customers. + */ +public class AccountNotVisibleException extends AbstractPlatformResourceNotFoundException { + + public AccountNotVisibleException(final Long accountId) { + super("error.msg.account.id.invalid", "Account with identifier " + accountId + " does not exist", accountId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountChargeWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountChargeWritePlatformService.java new file mode 100644 index 000000000..2143dbdcc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountChargeWritePlatformService.java @@ -0,0 +1,367 @@ +/** + * 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.math.BigDecimal; +import java.time.LocalDate; +import java.time.MonthDay; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.ProductToGLAccountMapping; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +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.service.DateUtils; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.charge.domain.ChargeAppliesTo; +import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Charges a teller adds to a current account by hand: add, edit, delete, pay, waive, inactivate. + * + * The CURRENTACCOUNTCHARGE commands used to go to the savings charge service with the current + * account's id, so they read and wrote m_savings_account / m_savings_account_charge. That either + * failed, or charged a same-numbered savings account and posted to that savings product's GL. The + * fee never reached the current product's income account, and so never reached the year's profit. + * + * Everything here works on m_current_account_charge, the table the account's Charges tab reads. + * Only paying moves money: the account is debited with a Charge Deduction and the ledger gets + * Dr Current Control / Cr Income From Fees (Income From Penalties for a penalty, when mapped). + * Waiving books nothing: on the cash basis the fee was never income, so there is nothing to undo. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CurrentAccountChargeWritePlatformService { + + private final CurrentAccountRepository currentAccountRepository; + private final CurrentAccountTransactionRepository currentAccountTransactionRepository; + private final CurrentProductRepository currentProductRepository; + private final ChargeRepositoryWrapper chargeRepository; + private final JournalEntryRepository journalEntryRepository; + private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; + private final KycRestrictionGuard kycRestrictionGuard; + + @Transactional + public CommandProcessingResult addCharge(final Long accountId, final JsonCommand command) { + final CurrentAccount account = findAccount(accountId); + if (account.getStatusEnum() == null || account.getStatusEnum() >= 600) { + throw rule("error.msg.current.account.charge.account.closed", "Charges cannot be added to a closed current account.", + accountId); + } + final Charge charge = chargeRepository.findOneWithNotFoundDetection(command.longValueOfParameterNamed("chargeId")); + // Savings-type charges are accepted too: current accounts' facility charges are defined that way, + // and a tenant may have no current-account-type charge at all. + if (!ChargeAppliesTo.CURRENT_ACCOUNT.getValue().equals(charge.getChargeAppliesTo()) + && !ChargeAppliesTo.SAVINGS.getValue().equals(charge.getChargeAppliesTo())) { + throw rule("error.msg.current.account.charge.not.current.account.charge", + "Charge '" + charge.getName() + "' is not defined for savings or current accounts.", charge.getId()); + } + if (!charge.isActive() || charge.isDeleted()) { + throw rule("error.msg.current.account.charge.inactive", "Charge '" + charge.getName() + "' is inactive.", charge.getId()); + } + + final BigDecimal amount = command.parameterExists("amount") ? command.bigDecimalValueOfParameterNamed("amount") + : charge.getAmount(); + requirePositive(amount, "amount"); + + final CurrentAccountCharge accountCharge = CurrentAccountCharge.createFromFacility(account, charge); + accountCharge.setAmount(amount); + accountCharge.setAmountOutstandingDerived(amount); + if (command.parameterExists("dueDate")) { + accountCharge.setChargeDueDate(command.localDateValueOfParameterNamed("dueDate")); + } + if (command.parameterExists("feeOnMonthDay")) { + final MonthDay feeOnMonthDay = command.extractMonthDayNamed("feeOnMonthDay"); + if (feeOnMonthDay != null) { + accountCharge.setFeeOnMonth(feeOnMonthDay.getMonthValue()); + accountCharge.setFeeOnDay(feeOnMonthDay.getDayOfMonth()); + } + } + if (command.parameterExists("feeInterval")) { + accountCharge.setFeeInterval(command.integerValueOfParameterNamed("feeInterval")); + } + account.getCharges().add(accountCharge); + currentAccountRepository.saveAndFlush(account); + + return result(account, accountCharge.getId(), null); + } + + @Transactional + public CommandProcessingResult updateCharge(final Long accountId, final Long accountChargeId, final JsonCommand command) { + final CurrentAccount account = findAccount(accountId); + final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId); + requireUntouched(accountCharge, "edited"); + + final Map changes = new LinkedHashMap<>(); + if (command.parameterExists("amount")) { + final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount"); + requirePositive(amount, "amount"); + accountCharge.setAmount(amount); + accountCharge.setAmountOutstandingDerived(amount); + changes.put("amount", amount); + } + if (command.parameterExists("dueDate")) { + final LocalDate dueDate = command.localDateValueOfParameterNamed("dueDate"); + accountCharge.setChargeDueDate(dueDate); + changes.put("dueDate", dueDate); + } + currentAccountRepository.saveAndFlush(account); + return result(account, accountChargeId, changes); + } + + @Transactional + public CommandProcessingResult deleteCharge(final Long accountId, final Long accountChargeId) { + final CurrentAccount account = findAccount(accountId); + final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId); + requireUntouched(accountCharge, "deleted"); + account.getCharges().remove(accountCharge); + currentAccountRepository.saveAndFlush(account); + return result(account, accountChargeId, null); + } + + /** + * Takes the fee from the account and books it as the bank's income. + * + * The payment date is the teller's {@code dueDate}, defaulting to the business date, and may not + * be in the future. The amount defaults to what is outstanding and may not exceed it. + */ + @Transactional + public CommandProcessingResult payCharge(final Long accountId, final Long accountChargeId, final JsonCommand command) { + final CurrentAccount account = findAccount(accountId); + final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId); + if (!accountCharge.isActive()) { + throw rule("error.msg.current.account.charge.inactive", "An inactive charge cannot be paid.", accountChargeId); + } + final BigDecimal outstanding = zeroIfNull(accountCharge.getAmountOutstandingDerived()); + if (accountCharge.isPaid() || accountCharge.isWaived() || outstanding.signum() <= 0) { + throw rule("error.msg.current.account.charge.nothing.outstanding", "Charge " + accountChargeId + " has nothing outstanding.", + accountChargeId); + } + final BigDecimal amount = command.parameterExists("amount") ? command.bigDecimalValueOfParameterNamed("amount") : outstanding; + requirePositive(amount, "amount"); + if (amount.compareTo(outstanding) > 0) { + throw rule("error.msg.current.account.charge.overpayment", + "Payment of " + amount + " exceeds the " + outstanding + " outstanding on charge " + accountChargeId + ".", + accountChargeId); + } + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate paymentDate = command.parameterExists("dueDate") && command.localDateValueOfParameterNamed("dueDate") != null + ? command.localDateValueOfParameterNamed("dueDate") + : today; + if (paymentDate.isAfter(today)) { + throw rule("error.msg.current.account.charge.payment.date.in.future", + "A charge cannot be paid on " + paymentDate + ", which is after the business date " + today + ".", accountChargeId); + } + + if (!account.isActive()) { + throw rule("error.msg.account.not.active", "Current account " + accountId + " is not active.", accountId); + } + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT); + final BigDecimal newBalance = assertBalanceCovers(account, amount); + + final CurrentAccountTransaction txn = CurrentAccountTransaction.chargeDeduction(account, account.getOffice(), amount, paymentDate); + txn.setRunningBalanceDerived(newBalance); + currentAccountTransactionRepository.saveAndFlush(txn); + account.setAccountBalanceDerived(newBalance); + + final BigDecimal paid = zeroIfNull(accountCharge.getAmountPaidDerived()).add(amount); + final BigDecimal remaining = outstanding.subtract(amount); + accountCharge.setAmountPaidDerived(paid); + accountCharge.setAmountOutstandingDerived(remaining); + accountCharge.setPaid(remaining.signum() == 0); + currentAccountRepository.saveAndFlush(account); + + postChargeIncome(account, accountCharge, txn); + log.info("Paid current account charge {} ({}) of {} from account {}", accountChargeId, accountCharge.getCharge().getName(), amount, + accountId); + + final Map changes = new LinkedHashMap<>(); + changes.put("amountPaid", amount); + changes.put("transactionId", txn.getId()); + return result(account, accountChargeId, changes); + } + + @Transactional + public CommandProcessingResult waiveCharge(final Long accountId, final Long accountChargeId) { + final CurrentAccount account = findAccount(accountId); + final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId); + final BigDecimal outstanding = zeroIfNull(accountCharge.getAmountOutstandingDerived()); + if (accountCharge.isWaived() || outstanding.signum() <= 0) { + throw rule("error.msg.current.account.charge.nothing.outstanding", "Charge " + accountChargeId + " has nothing to waive.", + accountChargeId); + } + accountCharge.setAmountWaivedDerived(zeroIfNull(accountCharge.getAmountWaivedDerived()).add(outstanding)); + accountCharge.setAmountOutstandingDerived(BigDecimal.ZERO); + accountCharge.setWaived(true); + currentAccountRepository.saveAndFlush(account); + + final Map changes = new LinkedHashMap<>(); + changes.put("amountWaived", outstanding); + return result(account, accountChargeId, changes); + } + + @Transactional + public CommandProcessingResult inactivateCharge(final Long accountId, final Long accountChargeId) { + final CurrentAccount account = findAccount(accountId); + final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId); + if (!accountCharge.isActive()) { + throw rule("error.msg.current.account.charge.already.inactive", "Charge " + accountChargeId + " is already inactive.", + accountChargeId); + } + accountCharge.setActive(false); + accountCharge.setInactivatedOnDate(DateUtils.getBusinessLocalDate()); + currentAccountRepository.saveAndFlush(account); + return result(account, accountChargeId, null); + } + + /** + * Dr the product's Current Control (the bank owes the customer less), Cr its fee income — or its + * penalty income for a penalty, where the product maps one. Skipped only when the product keeps no + * accounts (rule NONE), the same as the module's other postings; a product that does keep + * accounts but lacks a mapping fails the payment rather than debit the customer off the books. + */ + private void postChargeIncome(final CurrentAccount account, final CurrentAccountCharge accountCharge, + final CurrentAccountTransaction txn) { + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + if (product == null || product.getAccountingType() == null + || product.getAccountingType().equals(AccountingRuleType.NONE.getValue())) { + log.debug("Current product {} keeps no accounts - charge {} posted no journal entry", account.getProductId(), + accountCharge.getId()); + return; + } + final GLAccount control = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + GLAccount income = accountCharge.isPenalty() + ? findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_PENALTIES.getValue()) + : null; + if (income == null) { + income = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue()); + } + if (control == null || income == null) { + throw rule("error.msg.current.account.charge.gl.not.mapped", "Current product '" + product.getName() + "' has no " + + (control == null ? "Current Control" : "Income From Fees") + " GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + + final String transactionRef = "CAC" + txn.getId(); + final String description = "Current account charge - " + accountCharge.getCharge().getName(); + postEntry(account, control, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), transactionRef, description); + postEntry(account, income, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), transactionRef, description); + } + + private void postEntry(final CurrentAccount account, final GLAccount glAccount, final JournalEntryType entryType, + final BigDecimal amount, final LocalDate date, final String transactionRef, final String description) { + final JournalEntry entry = JournalEntry.createNew(account.getOffice(), null, glAccount, account.getCurrencyCode(), transactionRef, + false, date, entryType, amount, description, PortfolioProductType.CURRENT.getValue(), account.getId(), null, null, null, + null, null); + journalEntryRepository.save(entry); + } + + private GLAccount findGlAccount(final Long productId, final int financialAccountType) { + final ProductToGLAccountMapping mapping = productToGLAccountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(productId, PortfolioProductType.CURRENT.getValue(), + financialAccountType, null); + return mapping != null ? mapping.getGlAccount() : null; + } + + /** The balance after the charge, once it is clear the account can bear it. Same rules as the cheque book charge. */ + private BigDecimal assertBalanceCovers(final CurrentAccount account, final BigDecimal amount) { + final BigDecimal balance = zeroIfNull(account.getAccountBalanceDerived()); + final BigDecimal available = balance.subtract(zeroIfNull(account.getOnHoldFundsDerived())); + if (!account.isAllowOverdraft() && amount.compareTo(available) > 0) { + throw rule("error.msg.account.insufficient.balance", "Current account " + account.getId() + + " has insufficient available balance for a charge of " + amount + ". Available: " + available + ".", account.getId()); + } + final BigDecimal newBalance = balance.subtract(amount); + if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null + && account.getMinRequiredBalance().signum() > 0 && newBalance.compareTo(account.getMinRequiredBalance()) < 0) { + throw rule("error.msg.current.account.charge.min.required.balance.breached", + "A charge of " + amount + " would take current account " + account.getId() + " below its minimum required balance of " + + account.getMinRequiredBalance() + ".", + account.getId()); + } + return newBalance; + } + + private CurrentAccount findAccount(final Long accountId) { + return currentAccountRepository.findById(accountId).orElseThrow(() -> new SavingsAccountNotFoundException(accountId)); + } + + private CurrentAccountCharge findCharge(final CurrentAccount account, final Long accountChargeId) { + return account.getCharges().stream().filter(c -> accountChargeId.equals(c.getId())).findFirst() + .orElseThrow(() -> rule("error.msg.current.account.charge.not.found", + "Charge " + accountChargeId + " was not found on current account " + account.getId() + ".", accountChargeId)); + } + + /** Edits and deletes are only for a charge no money has touched; after that the record is history. */ + private void requireUntouched(final CurrentAccountCharge accountCharge, final String action) { + if (zeroIfNull(accountCharge.getAmountPaidDerived()).signum() > 0 || zeroIfNull(accountCharge.getAmountWaivedDerived()).signum() > 0 + || accountCharge.isPaid() || accountCharge.isWaived()) { + throw rule("error.msg.current.account.charge.already.settled", + "Charge " + accountCharge.getId() + " has been paid or waived and cannot be " + action + ".", accountCharge.getId()); + } + } + + private void requirePositive(final BigDecimal amount, final String field) { + if (amount == null || amount.signum() <= 0) { + throw rule("error.msg.current.account.charge." + field + ".not.positive", "Charge " + field + " must be greater than zero.", + amount); + } + } + + private static BigDecimal zeroIfNull(final BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private static GeneralPlatformDomainRuleException rule(final String code, final String message, final Object arg) { + return new GeneralPlatformDomainRuleException(code, message, arg); + } + + private CommandProcessingResult result(final CurrentAccount account, final Long accountChargeId, final Map changes) { + final CommandProcessingResultBuilder builder = new CommandProcessingResultBuilder().withEntityId(accountChargeId) + .withSavingsId(account.getId()).withOfficeId(account.getOffice() != null ? account.getOffice().getId() : null) + .withClientId(account.getClient() != null ? account.getClient().getId() : null); + if (changes != null) { + builder.with(changes); + } + return builder.build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountNoteRecorder.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountNoteRecorder.java new file mode 100644 index 000000000..2af0ded69 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountNoteRecorder.java @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.note.domain.Note; +import org.apache.fineract.portfolio.note.domain.NoteRepository; +import org.springframework.stereotype.Component; + +/** + * Saves the free-text "note" a user types on a current-account action screen (approve, reject, close, deposit...) + * as a note on that account, so it shows in the account's Notes tab - the same thing savings and loans already do + * with their action notes. The action is prefixed ("Approved: KYC complete") because, unlike a savings note, a + * current-account note has no transaction or status link to say where it came from. + */ +@Component +@RequiredArgsConstructor +public class CurrentAccountNoteRecorder { + + private static final String NOTE_PARAM = "note"; + // m_note.note is VARCHAR(1000) + private static final int MAX_NOTE_LENGTH = 1000; + + private final NoteRepository noteRepository; + + /** Records the command's "note", if one was typed; a blank or missing note records nothing. */ + public void record(final CurrentAccount account, final String action, final JsonCommand command) { + if (command == null || !command.parameterExists(NOTE_PARAM)) { + return; + } + final String typed = StringUtils.trimToNull(command.stringValueOfParameterNamed(NOTE_PARAM)); + if (typed == null) { + return; + } + final String text = StringUtils.abbreviate(action + ": " + typed, MAX_NOTE_LENGTH); + this.noteRepository.save(Note.currentAccountNote(account, text)); + } +} 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 1e0b19d61..8bdd8610e 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 @@ -31,6 +31,7 @@ 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.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService; import org.springframework.jdbc.core.JdbcTemplate; @@ -57,6 +58,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead + "LEFT JOIN m_currency curr ON curr.code = ca.currency_code "; private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; @Override public Map retrieveOne(Long accountId) { @@ -280,6 +282,74 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead } private List> retrieveCharges(Long accountId) { + return queryCharges("cac.current_account_id = ? AND cac.is_active = true", accountId); + } + + @Override + public List> retrieveCharges(final Long accountId, final String chargeStatus) { + if ("active".equalsIgnoreCase(chargeStatus)) { + return retrieveCharges(accountId); + } + if ("inactive".equalsIgnoreCase(chargeStatus)) { + return queryCharges("cac.current_account_id = ? AND cac.is_active = false", accountId); + } + return queryCharges("cac.current_account_id = ?", accountId); + } + + @Override + public Map retrieveCharge(final Long accountId, final Long accountChargeId) { + final List> rows = queryCharges("cac.current_account_id = ? AND cac.id = ?", accountId, accountChargeId); + if (rows.isEmpty()) { + throw new CurrentAccountNotFoundException(accountId); + } + // The charge detail page reads these alongside the list fields. + final Map charge = rows.get(0); + charge.put("accountId", accountId); + charge.put("dueDate", charge.get("chargeDueDate")); + charge.put("amountOrPercentage", charge.get("amount")); + final List> currency = jdbcTemplate.queryForList( + "SELECT curr.code, curr.name, curr.display_symbol FROM m_current_account ca " + + "JOIN m_currency curr ON curr.code = ca.currency_code WHERE ca.id = ?", + accountId); + if (!currency.isEmpty()) { + final Map c = new LinkedHashMap<>(); + c.put("code", currency.get(0).get("code")); + c.put("name", currency.get(0).get("name")); + c.put("displaySymbol", currency.get(0).get("display_symbol")); + c.put("displayLabel", currency.get(0).get("name") + " (" + currency.get(0).get("display_symbol") + ")"); + charge.put("currency", c); + } + return charge; + } + + @Override + public Map retrieveChargeTemplate(final Long accountId) { + // charge_applies_to_enum 2 = SAVINGS, 5 = CURRENT_ACCOUNT: the charges the add-charge write accepts. + final List> options = jdbcTemplate.query( + "SELECT ch.id, ch.name, ch.amount, ch.is_penalty, curr.code, curr.name AS currency_name, curr.display_symbol " + + "FROM m_current_account ca JOIN m_charge ch ON ch.currency_code = ca.currency_code " + + "LEFT JOIN m_currency curr ON curr.code = ch.currency_code " + + "WHERE ca.id = ? AND ch.charge_applies_to_enum IN (2, 5) AND ch.is_active = true AND ch.is_deleted = false " + + "ORDER BY ch.name", + (rs, rowNum) -> { + final Map option = new LinkedHashMap<>(); + option.put("id", rs.getLong("id")); + option.put("name", rs.getString("name")); + option.put("amount", rs.getBigDecimal("amount")); + option.put("penalty", rs.getBoolean("is_penalty")); + final Map currency = new LinkedHashMap<>(); + currency.put("code", rs.getString("code")); + currency.put("name", rs.getString("currency_name")); + currency.put("displaySymbol", rs.getString("display_symbol")); + option.put("currency", currency); + return option; + }, accountId); + final Map template = new LinkedHashMap<>(); + template.put("chargeOptions", options); + return template; + } + + private List> queryCharges(final String where, final Object... args) { final String sql = "SELECT cac.id, cac.charge_id, ch.name AS charge_name, " + "cac.charge_time_enum, cac.charge_due_date, cac.fee_on_month, cac.fee_on_day, " @@ -289,7 +359,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead + "cac.is_paid_derived, cac.waived, cac.is_penalty, cac.is_active " + "FROM m_current_account_charge cac " + "JOIN m_charge ch ON ch.id = cac.charge_id " - + "WHERE cac.current_account_id = ? AND cac.is_active = true " + + "WHERE " + where + " " + "ORDER BY cac.id"; return jdbcTemplate.query(sql, (rs, rowNum) -> { @@ -318,7 +388,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead c.put("penalty", rs.getBoolean("is_penalty")); c.put("isActive", rs.getBoolean("is_active")); return c; - }, accountId); + }, args); } private Map buildFeeOnMonthDay(Object month, Object day) { @@ -378,7 +448,14 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead final LocalDate asOnDate) { final StringBuilder sqlBuilder = new StringBuilder(BASE_SQL); final List params = new ArrayList<>(); - boolean hasWhere = false; + + // Branch scope: the list used to return every branch's current accounts to any user. An account belongs + // to the office it carries (ca.office_id), or its client's if that was left empty. Head Office is the + // root of the hierarchy, so it still sees all branches. + sqlBuilder.append(" WHERE EXISTS (SELECT 1 FROM m_office bo WHERE bo.id = COALESCE(ca.office_id, c.office_id) " + + "AND bo.hierarchy LIKE ?)"); + params.add(this.context.officeHierarchy() + "%"); + boolean hasWhere = true; if (StringUtils.isNotBlank(accountNo)) { sqlBuilder.append(hasWhere ? " AND " : " WHERE "); 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 3b9ee3487..fc626478c 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 @@ -69,6 +69,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren private final KycRestrictionGuard kycRestrictionGuard; private final AppUserRepositoryWrapper appUserRepositoryWrapper; private final CurrentAccountCashGlResolver cashGlResolver; + private final CurrentAccountNoteRecorder noteRecorder; @Transactional @Override @@ -108,6 +109,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren currentAccountRepository.save(account); postTransactionGl(account, txn, JournalEntryType.DEBIT, JournalEntryType.CREDIT, "Current Account Deposit"); + noteRecorder.record(account, "Deposit of " + amount.toPlainString() + " (txn #" + txn.getId() + ")", command); return new CommandProcessingResultBuilder() .withEntityId(txn.getId()) @@ -171,6 +173,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR branch cash GL (cash out) postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal"); + noteRecorder.record(account, "Withdrawal of " + amount.toPlainString() + " (txn #" + txn.getId() + ")", command); return new CommandProcessingResultBuilder() .withEntityId(txn.getId()) 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 0804c7ad7..08a0a6a2f 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 @@ -36,6 +36,7 @@ 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.savings.security.AccountBranchService; import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; @@ -77,6 +78,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri private final JournalEntryRepository journalEntryRepository; private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; private final CurrentAccountCashGlResolver cashGlResolver; + private final CurrentAccountNoteRecorder noteRecorder; + private final AccountBranchService accountBranchService; @Override @Transactional @@ -99,9 +102,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri "Client already has an active Current Account with the same product.", "productId", productId); } - final Office office = officeId != null - ? officeRepository.findOneWithNotFoundDetection(officeId) - : client.getOffice(); + // The branch named in the request, or the branch that is opening it for a customer of another branch, else the + // client's own. The request's officeId used to be taken on trust, with no check it was the caller's to use. + final Office resolvedOffice = accountBranchService.resolveAccountOffice(client, officeId); + final Office office = resolvedOffice != null ? resolvedOffice : client.getOffice(); final String dateFormat = getString(json, "dateFormat"); final String locale = getString(json, "locale"); @@ -271,6 +275,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.approve(approvedOnDate, user); currentAccountRepository.saveAndFlush(account); + noteRecorder.record(account, "Approved", command); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); } @@ -283,6 +288,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri account.setApprovedOnDate(null); account.setApprovedOnByUser(null); currentAccountRepository.saveAndFlush(account); + noteRecorder.record(account, "Approval undone", command); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); } @@ -317,6 +323,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.reject(rejectedOnDate, user); currentAccountRepository.saveAndFlush(account); + noteRecorder.record(account, "Rejected", command); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); } @@ -330,6 +337,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.withdraw(withdrawnOnDate, user); currentAccountRepository.saveAndFlush(account); + noteRecorder.record(account, "Withdrawn by client", command); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); } @@ -343,6 +351,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.close(closedOnDate, user); currentAccountRepository.saveAndFlush(account); + noteRecorder.record(account, "Closed", command); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); } 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 6afb3a5b8..80da129f2 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 @@ -115,6 +115,14 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead private static final RecurringDepositAccountMapper RECURRING_DEPOSIT_ACCOUNT_MAPPER = new RecurringDepositAccountMapper(); private static final DepositAccountLookupMapper DEPOSIT_ACCOUNT_LOOKUP_MAPPER = new DepositAccountLookupMapper(); private static final DepositAccountForMaturityMapper DEPOSIT_ACCOUNT_FOR_MATURITY_MAPPER = new DepositAccountForMaturityMapper(); + /** + * Branch scope for the FD/RD lists: only accounts whose branch (the one it was opened at, else its client's or group's office) sits at or below the caller's + * office. Head Office is the root, so it sees every branch. Applied to the API list methods only; the job-facing + * methods (maturity update, interest transfer, running RD ids) deliberately stay unscoped because they have no + * logged-in user. + */ + private static final String BRANCH_SCOPE_SQL = " and exists (select 1 from m_office bo where bo.id = coalesce(sa.office_id, c.office_id, g.office_id) and bo.hierarchy like ?) "; + // allowed column names for sorting the query result private static final Set supportedOrderByValues = new HashSet<>(Arrays.asList("id", "accountNumbr", "officeId", "officeName")); private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @@ -158,10 +166,12 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead sqlBuilder.append("select "); sqlBuilder.append(depositAccountMapper.schema()); sqlBuilder.append(" where sa.deposit_type_enum = ? "); + sqlBuilder.append(BRANCH_SCOPE_SQL); // always append at the end of a sql statement sqlBuilder.append(paginationParameters.paginationSql()); - return jdbcTemplate.query(sqlBuilder.toString(), depositAccountMapper, new Object[] { depositAccountType.getValue() }); // NOSONAR + return jdbcTemplate.query(sqlBuilder.toString(), depositAccountMapper, + new Object[] { depositAccountType.getValue(), this.context.officeHierarchy() + "%" }); // NOSONAR } @Override @@ -180,10 +190,11 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); sqlBuilder.append(depositAccountMapper.schema()); sqlBuilder.append(" where sa.deposit_type_enum = ? "); + sqlBuilder.append(BRANCH_SCOPE_SQL); sqlBuilder.append(paginationParameters.paginationSql()); - return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), new Object[] { depositAccountType.getValue() }, - depositAccountMapper); + return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), + new Object[] { depositAccountType.getValue(), this.context.officeHierarchy() + "%" }, depositAccountMapper); } @Override @@ -209,9 +220,11 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); sqlBuilder.append(depositAccountMapper.schema()); sqlBuilder.append(" where sa.deposit_type_enum = ? "); + sqlBuilder.append(BRANCH_SCOPE_SQL); final List params = new ArrayList<>(); params.add(depositAccountType.getValue()); + params.add(this.context.officeHierarchy() + "%"); if (StringUtils.isNotBlank(accountNo)) { sqlBuilder.append(" and sa.account_no like ? "); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java index 025d86582..5806ec574 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java @@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrap import org.apache.fineract.portfolio.savings.domain.SavingsProduct; import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository; import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; +import org.apache.fineract.portfolio.savings.security.AccountBranchService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.dao.DataAccessException; import org.springframework.transaction.annotation.Transactional; @@ -127,6 +128,7 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl private final SavingsAccountAssembler savingsAccountAssembler; private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; private final RecurringDepositProductRules recurringDepositProductRules; + private final AccountBranchService accountBranchService; /* * Guaranteed to throw an exception no matter what the data integrity issue is. @@ -185,6 +187,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl // A client may hold any number of Fixed Deposits on the same product, even while an earlier one is // still active, so there is deliberately no one-active-account-per-product check here. + // Held at the branch that opens it when the customer belongs to another branch; otherwise unchanged. + this.accountBranchService.applyAccountOffice(account, command); final MathContext mc = MathContext.DECIMAL64; final boolean isPreMatureClosure = false; @@ -262,6 +266,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl // A client may hold any number of Recurring Deposits on the same product, even while an earlier one // is still active, so there is deliberately no one-active-account-per-product check here. + // Held at the branch that opens it when the customer belongs to another branch; otherwise unchanged. + this.accountBranchService.applyAccountOffice(account, command); this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java index c2c25dcc6..b424078e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java @@ -77,7 +77,7 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest final int sr = slabRequest.getSrNo() != null ? slabRequest.getSrNo() : srCounter.getAndIncrement(); final RdBaseInterestRateSlabHistory slab = RdBaseInterestRateSlabHistory.create(versionId, sr, slabRequest.getDescription(), slabRequest.getPeriodTypeEnum(), slabRequest.getFromPeriod(), slabRequest.getToPeriod(), - slabRequest.getAnnualInterestRate()); + slabRequest.getAnnualInterestRate(), slabRequest.getSeniorCitizenRate()); slabRepository.save(slab); } @@ -111,13 +111,16 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest .thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod())); final List normalized = new ArrayList<>(sorted.size()); - Integer expectedFrom = 0; + // A chart in days may open at 0; one in months or years opens at 1. Either way each slab then follows on from the last. + Integer expectedFrom = null; for (int i = 0; i < sorted.size(); i++) { final RdInterestRateSaveRequest.SlabRequest source = sorted.get(i); final RdInterestRateSaveRequest.SlabRequest target = new RdInterestRateSaveRequest.SlabRequest(); final Integer fromPeriod = source.getFromPeriod(); - if (fromPeriod == null || !fromPeriod.equals(expectedFrom)) { + final boolean startsRight = fromPeriod != null + && (i == 0 ? (fromPeriod == 0 || fromPeriod == 1) : fromPeriod.equals(expectedFrom)); + if (!startsRight) { throwValidationError("fromPeriod", "must.start.from.previous.range.end"); } target.setFromPeriod(fromPeriod); @@ -147,13 +150,15 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest if (target.getAnnualInterestRate().signum() < 0) { throwValidationError("annualInterestRate", "must.be.non.negative"); } + // The senior-citizen rate is optional: left blank it means this slab carries no premium. + target.setSeniorCitizenRate(source.getSeniorCitizenRate() == null ? java.math.BigDecimal.ZERO : source.getSeniorCitizenRate()); + if (target.getSeniorCitizenRate().signum() < 0) { + throwValidationError("seniorCitizenRate", "must.be.non.negative"); + } normalized.add(target); } - if (!normalized.isEmpty() && normalized.get(0).getFromPeriod() != 0) { - throwValidationError("fromPeriod", "must.start.from.zero"); - } return normalized; } @@ -181,6 +186,7 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest .fromPeriod(s.getFromPeriod()) .toPeriod(s.getToPeriod()) .annualInterestRate(s.getAnnualInterestRate()) + .seniorCitizenRate(s.getSeniorCitizenRate()) .build()) .collect(Collectors.toList()); 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 14b776752..18b251c31 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 @@ -271,7 +271,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); sqlBuilder.append(this.savingAccountMapper.schema()); - sqlBuilder.append(" join m_office o on o.id = c.office_id"); + // the branch the account is held at, else its client's: a customer of another branch can hold an account here + sqlBuilder.append(" join m_office o on o.id = coalesce(sa.office_id, c.office_id)"); sqlBuilder.append(" where o.hierarchy like ?"); final Object[] objectArray = new Object[15]; @@ -300,7 +301,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead arrayPos = arrayPos + 1; } if (searchParameters.getOfficeId() != null) { - sqlBuilder.append(" and c.office_id = ?"); + sqlBuilder.append(" and coalesce(sa.office_id, c.office_id) = ?"); objectArray[arrayPos++] = searchParameters.getOfficeId(); } if (StringUtils.isNotBlank(searchParameters.getAccountNo())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 3d347f4ff..216215704 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -174,6 +174,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi private final SavingsAccountInterestPostingService savingsAccountInterestPostingService; private final ErrorHandler errorHandler; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final org.apache.fineract.accounting.interbranch.service.InterBranchCashService interBranchCashService; @Transactional @Override @@ -340,6 +341,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(), account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate, "DEPOSIT"); + this.interBranchCashService.moveCashLegToCashierBranch(deposit.getId(), account.officeId(), account.getAccountNumber(), + transactionAmount, transactionDate, true); if (isGsim && (deposit.getId() != null)) { @@ -440,6 +443,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.denominationBreakdownWriter.save(command, withdrawal.getId(), account.getId(), account.getAccountNumber(), account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate, "WITHDRAWAL"); + this.interBranchCashService.moveCashLegToCashierBranch(withdrawal.getId(), account.officeId(), account.getAccountNumber(), + transactionAmount, transactionDate, false); final String noteText = command.stringValueOfParameterNamed("note"); if (StringUtils.isNotBlank(noteText)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java index b6268af91..10c44fccc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java @@ -86,6 +86,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; import org.apache.fineract.portfolio.savings.domain.SavingsProduct; import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository; import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; +import org.apache.fineract.portfolio.savings.security.AccountBranchService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.dao.DataAccessException; import org.springframework.transaction.annotation.Transactional; @@ -117,6 +118,7 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl private final GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService; private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + private final AccountBranchService accountBranchService; @Transactional @Override @@ -148,9 +150,15 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl final SavingsAccount account = this.savingAccountAssembler.assembleFrom(command, submittedBy); + // Opens the account at another branch than the client's when the request names one (and the customer is + // linked to it); a request without one leaves the account at the client's branch as before. + this.accountBranchService.applyAccountOffice(account, command); + if (account.getClient() != null && account.productId() != null) { - long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct( - account.getClient().getId(), account.productId()); + // One account per product per client PER BRANCH: the same product at another branch is another account + // in that branch's books. + long existingCount = this.accountBranchService.countOpenAccountsAtBranch(account.getClient().getId(), + account.productId(), account.officeId()); if (existingCount > 0) { throw new PlatformDataIntegrityException( "error.msg.savings.account.duplicate.product.for.client", 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 b1f5f99a4..8f9e0b836 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 @@ -326,14 +326,15 @@ public class SavingsConfiguration { FromJsonHelper fromJsonHelper, CalendarInstanceRepository calendarInstanceRepository, ConfigurationDomainService configurationDomainService, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, BusinessEventNotifierService businessEventNotifierService, SavingsAccountAssembler savingsAccountAssembler, - ClientNomineeWritePlatformService clientNomineeWritePlatformService, - RecurringDepositProductRules recurringDepositProductRules) { + ClientNomineeWritePlatformService clientNomineeWritePlatformService, RecurringDepositProductRules recurringDepositProductRules, + org.apache.fineract.portfolio.savings.security.AccountBranchService accountBranchService) { return new DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository, fixedDepositAccountRepository, recurringDepositAccountRepository, depositAccountAssembler, depositAccountDataValidator, accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository, noteRepository, staffRepository, savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler, accountAssociationsRepository, fromJsonHelper, calendarInstanceRepository, configurationDomainService, accountNumberFormatRepository, - businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService, recurringDepositProductRules); + businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService, recurringDepositProductRules, + accountBranchService); } @Bean @@ -486,7 +487,8 @@ public class SavingsConfiguration { StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator, SavingsDailyInterestService savingsDailyInterestService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, - ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) { + ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService, + org.apache.fineract.accounting.interbranch.service.InterBranchCashService interBranchCashService) { return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer, savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator, @@ -496,7 +498,7 @@ public class SavingsConfiguration { depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository, standingInstructionRepository, businessEventNotifierService, savingsDailyInterestOrchestrator, savingsDailyInterestService, gsimRepository, savingsAccountInterestPostingService, - errorHandler, denominationBreakdownWriter); + errorHandler, denominationBreakdownWriter, interBranchCashService); } @Bean @@ -541,13 +543,14 @@ public class SavingsConfiguration { AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, BusinessEventNotifierService businessEventNotifierService, EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, GSIMRepositoy gsimRepository, GroupRepositoryWrapper groupRepositoryWrapper, GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService, - ClientNomineeWritePlatformService clientNomineeWritePlatformService, org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) { + ClientNomineeWritePlatformService clientNomineeWritePlatformService, org.springframework.jdbc.core.JdbcTemplate jdbcTemplate, + org.apache.fineract.portfolio.savings.security.AccountBranchService accountBranchService) { return new SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository, savingAccountAssembler, savingsAccountDataValidator, accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository, noteRepository, staffRepository, savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler, commandProcessingService, savingsAccountDomainService, savingsAccountWritePlatformService, accountNumberFormatRepository, businessEventNotifierService, entityDatatableChecksWritePlatformService, gsimRepository, groupRepositoryWrapper, - gsimWritePlatformService, clientNomineeWritePlatformService, jdbcTemplate); + gsimWritePlatformService, clientNomineeWritePlatformService, jdbcTemplate, accountBranchService); } @Bean 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 54471b2db..1465132fb 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 @@ -29,6 +29,7 @@ import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.glaccount.domain.GLAccountType; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -1043,6 +1044,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // 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); + validateGlMappingTypes(chargesIncomeGlAccountId, gstPayableGlAccountId, cashGlAccountId, cashSuspenseGlAccountId); final List> existing = this.jdbcTemplate.queryForList( "SELECT id FROM m_sponsor_dd_gl_mapping LIMIT 1"); @@ -1083,6 +1085,47 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // ── Validation helpers ───────────────────────────────────────────────── + /** + * Each account on the DD settings screen has one job, and the job fixes its type. + * + * The year-end close sweeps every INCOME account into profit, so a GST account set up as income + * hands the government's money to the society's profit and on into the dividend. A charges + * account that is not income keeps the commission out of profit. The cash suspense holds a + * walk-in's notes between receipt and approval; pointed at the cash account itself, the receipt + * posts Dr Cash / Cr Cash and the pending money is never separately visible. + */ + private void validateGlMappingTypes(final Long chargesIncomeGlAccountId, final Long gstPayableGlAccountId, + final Long cashGlAccountId, final Long cashSuspenseGlAccountId) { + requireGlType(chargesIncomeGlAccountId, GLAccountType.INCOME, "DD Charges / Commission Income"); + requireGlType(gstPayableGlAccountId, GLAccountType.LIABILITY, "GST Payable on Charges"); + requireGlType(cashGlAccountId, GLAccountType.ASSET, "Cash at Tellers / Vault"); + if (cashSuspenseGlAccountId == null) { + throw new IllegalArgumentException("DD Cash Suspense GL account is required."); + } + if (cashSuspenseGlAccountId.equals(cashGlAccountId)) { + throw new IllegalArgumentException( + "DD Cash Suspense GL must be a different account from the Cash GL - cash held for a DD awaiting approval has to be visible on its own."); + } + } + + private void requireGlType(final Long glAccountId, final GLAccountType expected, final String label) { + if (glAccountId == null) { + throw new IllegalArgumentException(label + " GL account is required."); + } + final List> rows = this.jdbcTemplate.queryForList( + "SELECT gl_code, name, classification_enum FROM acc_gl_account WHERE id = ?", glAccountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException(label + " GL account " + glAccountId + " does not exist."); + } + final Map gl = rows.get(0); + final int actual = ((Number) gl.get("classification_enum")).intValue(); + if (actual != expected.getValue()) { + final GLAccountType actualType = GLAccountType.fromInt(actual); + throw new IllegalArgumentException(label + " GL must be a " + expected.name() + " account, but " + gl.get("gl_code") + " " + + gl.get("name") + " is " + (actualType != null ? actualType.name() : "type " + actual) + "."); + } + } + private final com.google.gson.Gson gson = new com.google.gson.Gson(); private void validateDenominationTotal(final String denominationData, final BigDecimal expectedTotal) { 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 959cdfcfa..ff2789813 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 @@ -178,4 +178,15 @@ + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_current_account_notes.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_current_account_notes.xml new file mode 100644 index 000000000..f086a700f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_current_account_notes.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_create_profit_appropriation_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_create_profit_appropriation_config.xml new file mode 100644 index 000000000..4b1eab842 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_create_profit_appropriation_config.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_PROFITAPPROPRIATIONCONFIG', 'UPDATE_PROFITAPPROPRIATIONCONFIG') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_PROFITAPPROPRIATIONCONFIG', 'UPDATE_PROFITAPPROPRIATIONCONFIG') + + + + + + 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_PROFITAPPROPRIATIONCONFIG', 'UPDATE_PROFITAPPROPRIATIONCONFIG') + 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/0601_create_profit_appropriation_posting.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_create_profit_appropriation_posting.xml new file mode 100644 index 000000000..3f13f3af7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_create_profit_appropriation_posting.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_add_aadhaar_hash_to_client_kyc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_add_aadhaar_hash_to_client_kyc.xml new file mode 100644 index 000000000..2199ab1d1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_add_aadhaar_hash_to_client_kyc.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_savings_account_branch_and_client_branch_link.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_savings_account_branch_and_client_branch_link.xml new file mode 100644 index 000000000..9bb9e2358 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_savings_account_branch_and_client_branch_link.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_add_kyc_reminders_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_add_kyc_reminders_job.xml new file mode 100644 index 000000000..af44110b4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_add_kyc_reminders_job.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0605_create_kyc_notice_queue.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0605_create_kyc_notice_queue.xml new file mode 100644 index 000000000..7753f64c8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0605_create_kyc_notice_queue.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_add_ctr_monthly_report_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_add_ctr_monthly_report_job.xml new file mode 100644 index 000000000..ffacc3b00 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_add_ctr_monthly_report_job.xml @@ -0,0 +1,61 @@ + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'AML Monthly CTR Report Generation' + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_rd_base_chart_senior_rate_and_months.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_rd_base_chart_senior_rate_and_months.xml new file mode 100644 index 000000000..7336a101a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0606_rd_base_chart_senior_rate_and_months.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM m_rd_base_interest_rate_slab_history s + JOIN m_rd_base_interest_rate_effective e ON e.id = s.effective_id + WHERE e.is_active = true AND s.period_type_enum = 1 + ) THEN 1 ELSE 0 END + + + + UPDATE m_rd_base_interest_rate_effective SET is_active = false, effective_to_date = CURRENT_DATE WHERE is_active = true; + + + INSERT INTO m_rd_base_interest_rate_effective (version_label, effective_from_date, is_active, remarks, created_date) + VALUES ('RD-MONTHS-V1', CURRENT_DATE, true, 'RD base chart in months, carrying the senior-citizen rate', CURRENT_DATE); + + + INSERT INTO m_rd_base_interest_rate_slab_history + (effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date) + SELECT e.id, 1, '1 to 3 Months', 2, 1, 3, 3, 3.5, CURRENT_DATE + FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1'; + INSERT INTO m_rd_base_interest_rate_slab_history + (effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date) + SELECT e.id, 2, '4 to 5 Months', 2, 4, 5, 4.5, 5, CURRENT_DATE + FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1'; + INSERT INTO m_rd_base_interest_rate_slab_history + (effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date) + SELECT e.id, 3, '6 to 11 Months', 2, 6, 11, 5.5, 6, CURRENT_DATE + FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1'; + INSERT INTO m_rd_base_interest_rate_slab_history + (effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date) + SELECT e.id, 4, '12 to 35 Months', 2, 12, 35, 6.5, 7, CURRENT_DATE + FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1'; + INSERT INTO m_rd_base_interest_rate_slab_history + (effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date) + SELECT e.id, 5, '36 to 71 Months', 2, 36, 71, 6.75, 7.25, CURRENT_DATE + FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1'; + INSERT INTO m_rd_base_interest_rate_slab_history + (effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date) + SELECT e.id, 6, '72 to 131 Months', 2, 72, 131, 7, 7.5, CURRENT_DATE + FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1'; + INSERT INTO m_rd_base_interest_rate_slab_history + (effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date) + SELECT e.id, 7, '132+ Months', 2, 132, NULL, 7.25, 7.75, CURRENT_DATE + FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0607_cc_relevant_risk_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0607_cc_relevant_risk_rules.xml new file mode 100644 index 000000000..3f5216955 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0607_cc_relevant_risk_rules.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + id IN (49, 50, 51, 59, 69, 73, 90, 91, 94, 97, 101, 102, 103, 104, 105, 106, 107, 108, 109, 112, 123, + 142, 143, 168, 170, 172, 186, 189, 190, 194, 195, 196, 212) AND rule_kind = 'AML' + + + + + + + SELECT COUNT(*) FROM m_client_risk_rule WHERE rule_name LIKE 'CC - %' + + + INSERT INTO m_client_risk_rule + (rule_name, risk_category, reason, order_position, aml_check_status, banned_entity_match, + willful_defaulter, ecgc_sal_flag, dormant_flag, rule_kind, coverage_status, coverage_note, cc_relevant) + SELECT v.rule_name, v.risk_category, v.rule_name, (SELECT COALESCE(MAX(order_position), 0) FROM m_client_risk_rule) + v.seq, + 'FLAGGED', 'NO', 'NO', 'NO', 'ACTIVE', 'AML', 'PLANNED', + 'Needs the AML engine to be fed current-account transactions; until then the CC Accounts tab shows the figures.', + TRUE + FROM (VALUES + (1, 'CC - Monthly credit turnover exceeds declared turnover or sanctioned limit', 3), + (2, 'CC - Monthly debit turnover exceeds declared turnover or sanctioned limit', 3), + (3, 'CC - Funds received and substantially withdrawn within 1 to 3 days', 3), + (4, 'CC - Sudden spike in turnover against the account''s own 3-month average', 2), + (5, 'CC - High share of cash in credits or cash withdrawals from the account', 3), + (6, 'CC - Limit drawn to the sanction and cleared immediately (round tripping)', 3), + (7, 'CC - Repeated transfers with the same counterparty (counterparty concentration)', 2), + (8, 'CC - Circular movement of funds returning to the customer or a linked account', 3), + (9, 'CC - Third-party fund flow not explained by the declared business', 3) + ) AS v(seq, rule_name, risk_category); + + + + \ No newline at end of file diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_apply_rd_base_chart_to_rd_products.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_apply_rd_base_chart_to_rd_products.xml new file mode 100644 index 000000000..23eb2039e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_apply_rd_base_chart_to_rd_products.xml @@ -0,0 +1,74 @@ + + + + + + + + + SELECT CASE WHEN + EXISTS (SELECT 1 FROM m_rd_base_interest_rate_effective WHERE is_active = true) + AND NOT EXISTS ( + SELECT 1 FROM m_rd_base_interest_rate_slab_history s + JOIN m_rd_base_interest_rate_effective e ON e.id = s.effective_id + WHERE e.is_active = true AND s.period_type_enum NOT IN (2, 3)) + THEN 1 ELSE 0 END + + + + UPDATE m_interest_rate_chart + SET name = (SELECT MAX(e.version_label) FROM m_rd_base_interest_rate_effective e WHERE e.id = (SELECT MAX(x.id) FROM m_rd_base_interest_rate_effective x WHERE x.is_active = true)), + description = 'Active RD CBS base chart' + WHERE id IN ( + SELECT dpc.interest_rate_chart_id FROM m_deposit_product_interest_rate_chart dpc + JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id + WHERE sp.deposit_type_enum = 300); + + + DELETE FROM m_interest_rate_slab + WHERE interest_rate_chart_id IN ( + SELECT dpc.interest_rate_chart_id FROM m_deposit_product_interest_rate_chart dpc + JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id + WHERE sp.deposit_type_enum = 300); + + + INSERT INTO m_interest_rate_slab + (interest_rate_chart_id, description, period_type_enum, from_period, to_period, annual_interest_rate, currency_code) + SELECT dpc.interest_rate_chart_id, s.description, s.period_type_enum, s.from_period, s.to_period, s.annual_interest_rate, sp.currency_code + FROM m_deposit_product_interest_rate_chart dpc + JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id AND sp.deposit_type_enum = 300 + JOIN m_rd_base_interest_rate_slab_history s + ON s.effective_id = (SELECT MAX(x.id) FROM m_rd_base_interest_rate_effective x WHERE x.is_active = true); + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java index 1eb944297..ecc096e99 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RdInterestRateSlabData.java @@ -27,4 +27,5 @@ public final class RdInterestRateSlabData { private final Integer fromPeriod; private final Integer toPeriod; private final BigDecimal annualInterestRate; + private final BigDecimal seniorCitizenRate; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java index 83a44b611..eb010b559 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java @@ -25,13 +25,19 @@ import org.apache.fineract.portfolio.savings.SavingsApiConstants; public class SavingsAccountConstant extends SavingsApiConstants { + /** + * The branch to open the account at, when it is not the client's own branch. Optional; absent means the + * client's branch. See SavingsAccount#office. + */ + public static final String accountOfficeIdParamName = "accountOfficeId"; + /** * These parameters will match the class level parameters of {@link SavingsProductData}. Where possible, we try to * get response parameters to match those of request parameters. */ protected static final Set SAVINGS_ACCOUNT_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName, dateFormatParamName, monthDayFormatParamName, staffIdParamName, isGSIM, isParentAccount, accountNoParamName, - externalIdParamName, clientIdParamName, groupIdParamName, productIdParamName, fieldOfficerIdParamName, submittedOnDateParamName, + externalIdParamName, clientIdParamName, accountOfficeIdParamName, groupIdParamName, productIdParamName, fieldOfficerIdParamName, submittedOnDateParamName, nominalAnnualInterestRateParamName, interestCompoundingPeriodTypeParamName, interestPostingPeriodTypeParamName, interestCalculationTypeParamName, interestCalculationDaysInYearTypeParamName, minRequiredOpeningBalanceParamName, lockinPeriodFrequencyParamName, lockinPeriodFrequencyTypeParamName, 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 07fee0bc1..a9c40b171 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 @@ -160,6 +160,15 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom @JoinColumn(name = "group_id", nullable = true) protected Group group; + /** + * The branch this account is held at, when that is not the client's own branch (a customer of Head Office who + * opens an account at Bhopal). Null for every ordinary account, meaning "the client's branch". It decides the + * branch of the account's transactions and journal entries; see {@link #office()}. + */ + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "office_id", nullable = true) + protected Office accountOffice; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "gsim_id", nullable = true) private GroupSavingsIndividualMonitoring gsim; @@ -2601,7 +2610,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom public Long officeId() { Long officeId = null; - if (this.client != null) { + if (this.accountOffice != null) { + officeId = this.accountOffice.getId(); + } else if (this.client != null) { officeId = this.client.officeId(); } else if (this.group != null) { officeId = this.group.officeId(); @@ -2609,9 +2620,15 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return officeId; } + /** + * The branch whose books this account is in: the branch it was opened at when that differs from the client's, + * otherwise the client's (or group's) branch. Every transaction and journal entry takes its branch from here. + */ public Office office() { Office office = null; - if (this.client != null) { + if (this.accountOffice != null) { + office = this.accountOffice; + } else if (this.client != null) { office = this.client.getOffice(); } else if (this.group != null) { office = this.group.getOffice(); @@ -2663,6 +2680,15 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return this.savingsProduct().getId(); } + /** Opens this account at a branch other than its client's. Pass null to keep it at the client's branch. */ + public void setAccountOffice(final Office accountOffice) { + this.accountOffice = accountOffice; + } + + public Office getAccountOffice() { + return this.accountOffice; + } + public void setGsim(GroupSavingsIndividualMonitoring gsim) { this.gsim = gsim; }