diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 172e39de7..9a34bd589 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -79,6 +79,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @@ -102,6 +103,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer private final CurrentAccountTransactionRepository currentAccountTransactionRepository; private final AccountingProcessorHelper accountingProcessorHelper; private final KycRestrictionGuard kycRestrictionGuard; + private final SavingsInterestTdsService savingsInterestTdsService; @Transactional @Override @@ -412,6 +414,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer toSavingsAccount, withdrawal, deposit); this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails); transferTransactionId = accountTransferDetails.getId(); + // FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted. + this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit, + AccountTransferType.fromInt(accountTransferDTO.getTransferType())); } else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) { @@ -703,8 +708,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer currentAccountRepository.save(account); if (account.getProductId() != null && txn.getId() != null) { final String currentTxnId = "CA" + txn.getId(); - accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(), - account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(), + accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(), + account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getId(), currentTxnId, txnDate, amount); } return txn; @@ -715,7 +720,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient. */ private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) { kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; @@ -735,7 +740,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent, + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent, counterpartEntityId); return txn; } @@ -744,7 +749,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Deposit amount into a current account and save the transaction. */ private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) { kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; @@ -757,7 +762,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent, + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent, counterpartEntityId); return txn; } @@ -803,13 +808,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final String currentTxnId = "CA" + txn.getId(); if (isWithdrawal) { // DR: current account GL CR: counterpart (savings/current) GL - accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), - account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId, + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId, currentTxnId, txnDate, amount); } else { // DR: counterpart (savings/current) GL CR: current account GL - accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), - counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(), + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(), currentTxnId, txnDate, amount); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java index 4c3d4bcfa..eee9b3400 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java @@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -97,12 +98,14 @@ public class AccountConfiguration { GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, CurrentAccountTransactionRepository currentAccountTransactionRepository, - AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) { + AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard, + SavingsInterestTdsService savingsInterestTdsService) { return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, - currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard); + currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard, + savingsInterestTdsService); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index de005116a..82224e25c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -122,11 +122,26 @@ public class BranchDayRegisterApiResource { result.put("officeId", officeId); result.put("canOperate", canOperateDay(currentUser)); result.put("days", mapDays(rows)); - result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString()); + result.put("today", DateUtils.getBusinessLocalDate().toString()); result.put("openDay", findOpenDay(officeId)); return gson.toJson(result); } + // ── GET /v1/branchday/today ── the bank's "today" ───────────────────────── + + /** + * The business date while enable-business-date is on, the tenant's own date while it is off. + * Every screen that defaults a date to "today" reads this, rather than the browser clock or the + * business-date API (which returns the stored date even when the feature is off). Open to any + * signed-in user — cashiers and clerks cannot read the configuration that says which applies. + */ + @GET + @Path("today") + public String today() { + this.context.authenticatedUser(); + return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString())); + } + // ── GET /v1/branchday/missed ── days left open ──────────────────────────── /** @@ -139,7 +154,7 @@ public class BranchDayRegisterApiResource { public String missed(@QueryParam("officeId") final Long officeIdParam) { final AppUser currentUser = this.context.authenticatedUser(); final Long officeId = resolveOffice(officeIdParam, currentUser); - final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate today = DateUtils.getBusinessLocalDate(); final List> rows = jdbcTemplate.queryForList(""" SELECT r.id, r.office_id, o.name AS office_name, r.business_date, @@ -253,7 +268,7 @@ public class BranchDayRegisterApiResource { final var root = this.fromJsonHelper.parse(body); final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); final LocalDate businessDate = extractDate(root, "businessDate", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + DateUtils.getBusinessLocalDate()); final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); @@ -447,7 +462,7 @@ public class BranchDayRegisterApiResource { final var root = this.fromJsonHelper.parse(body); final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); final LocalDate businessDate = extractDate(root, "businessDate", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + DateUtils.getBusinessLocalDate()); final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); @@ -801,7 +816,7 @@ public class BranchDayRegisterApiResource { final Long officeId = resolveOffice(officeIdParam, currentUser); final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank()) ? LocalDate.parse(businessDateParam) - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final List registerIds = jdbcTemplate.queryForList( "SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 003da0a43..9ffd57359 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -727,7 +727,7 @@ public class CashierDepositRequestApiResource { final Long savingsAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -761,7 +761,7 @@ public class CashierDepositRequestApiResource { final Long accountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -790,7 +790,7 @@ public class CashierDepositRequestApiResource { final Long accountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -844,7 +844,7 @@ public class CashierDepositRequestApiResource { return; } - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -867,7 +867,7 @@ public class CashierDepositRequestApiResource { final Long currentAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -976,7 +976,7 @@ public class CashierDepositRequestApiResource { final Object paymentTypeId, final String txnDateStr, final String note, final boolean isDeposit) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; // Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra) @@ -1073,8 +1073,9 @@ public class CashierDepositRequestApiResource { value, count, BigDecimal.valueOf((long) value * count), // Dated on the request's own date, which is what the branch day's book figure reads. // CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the - // drawer on another and leave both days' cash wrong. - txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null, + // drawer on another and leave both days' cash wrong. No date falls back to the + // business date for the same reason. + Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()), cashierId, cashierUsername, paymentTypeName ); } @@ -1179,7 +1180,7 @@ public class CashierDepositRequestApiResource { final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE); return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } catch (Exception e) { - return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 89a350f01..20d360fa0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -83,7 +83,7 @@ public class CashReportApiResource { public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side, @QueryParam("status") final String status) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); final List> rows = new ArrayList<>(); @@ -470,7 +470,7 @@ public class CashReportApiResource { @GET @Path("closing") public String closing(@QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); // A closing report for a cashier is that one drawer, so it is scoped the way `excess` and // `cashierPosition` already are; a head cashier or admin gets null and still sees the branch. @@ -938,7 +938,7 @@ public class CashReportApiResource { return gson.toJson(result); } - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String today = DateUtils.getBusinessLocalDate().toString(); // Find the earliest transfer date before today for which no settlement exists on or after that date final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled " @@ -1001,7 +1001,7 @@ public class CashReportApiResource { @Path("cashier-position") public String cashierPosition(@QueryParam("cashierId") Long cashierId, @QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString(); // CASHIER role: always scope to their own position only final Long scopedId = cashierRoleHelper.getCashierIdForFiltering(); @@ -1032,7 +1032,7 @@ public class CashReportApiResource { @GET @Path("cashier-position/me") public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString(); Long appUserId; try { appUserId = context.authenticatedUser().getId(); @@ -1070,7 +1070,7 @@ public class CashReportApiResource { public String vaultPosition(@QueryParam("tellerId") final Long tellerId, @QueryParam("asOfDate") final String asOfDate) { final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + : DateUtils.getBusinessLocalDate().toString(); if (tellerId == null) { final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class); @@ -1092,7 +1092,7 @@ public class CashReportApiResource { @Path("cashier-morning") public String cashierMorning(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); if (cashierId == null) { Map empty = new LinkedHashMap<>(); empty.put("cashierId", null); @@ -1304,7 +1304,7 @@ public class CashReportApiResource { @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); final int resolvedLimit = (limit != null && limit > 0) ? limit : 20; final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 847ea66a8..61d6bbab8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -89,7 +89,7 @@ public class CashSettlementApiResource { } final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull() - ? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + ? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate(); final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString() : null; final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() @@ -226,7 +226,7 @@ public class CashSettlementApiResource { } glDispatchService.allocateCashToCashier(glTellerId, settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), + DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash settlement #" + id + (reason != null ? ": " + reason : "")); } catch (Exception ignored) { // intentionally ignored @@ -247,7 +247,7 @@ public class CashSettlementApiResource { throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required", "cashierId query param is required."); } - final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)", Integer.class, cashierId, settlementDate); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 5f5c64b62..abe44dc8d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -146,7 +146,7 @@ public class CashTransferApiResource { } catch (Exception ignored) { // intentionally ignored } - final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate(); glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total, currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE), note != null ? note : ""); @@ -206,7 +206,7 @@ public class CashTransferApiResource { } glDispatchService.settleCashFromCashier(glTellerId, transfer.getToCashierId(), transfer.getTransferAmount(), "INR", - java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), + DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash transfer #" + id + (reason != null ? ": " + reason : "")); } catch (Exception ignored) { // intentionally ignored @@ -291,7 +291,7 @@ public class CashTransferApiResource { "Vault/teller " + fromTellerId + " was not found."); } - final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate(); final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate); final List registerIds = jdbcTemplate.query(""" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java index 002638999..6aaa12dfb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java @@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource { final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root); final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank()) ? LocalDate.parse(txnDateStr) - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) { return "{\"result\":\"skipped\"}"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index b3164ea4a..9a02c384d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource { final Long resolvedClientId = toLong(dep.get("client_id")); final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String receiptNo = nextReceiptNo(); // What the cashier actually typed at save time, when they typed anything -- that is what // the Apply Share screen's Description column exists to show. Falls back to a plain @@ -927,7 +927,7 @@ public class ShareAdvanceCollectionApiResource { final Object submittedAt = req.get("submitted_at"); final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String voucherNo = (String) req.get("voucher_no"); final String receiptNo = nextReceiptNo(); final String reqDescription = (String) req.get("description"); @@ -1006,7 +1006,7 @@ public class ShareAdvanceCollectionApiResource { final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId); final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR"; final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); // What the cashier actually typed at save time, when they typed anything -- same preference // as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by @@ -1093,7 +1093,7 @@ public class ShareAdvanceCollectionApiResource { final Object submittedAt = req.get("submitted_at"); final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String receiptNo = nextReceiptNo(); final String reqDescription = (String) req.get("description"); @@ -1338,7 +1338,7 @@ public class ShareAdvanceCollectionApiResource { private LocalDate parseDate(final String raw) { if (raw == null || raw.isBlank()) { - return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + return DateUtils.getBusinessLocalDate(); } try { return LocalDate.parse(raw); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java index 3b40943d3..afa3a01a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -210,12 +210,14 @@ public class RbiReserveConfigApiResource { ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter"); } else { ROLE_GROUPS.put(role, "RESERVE"); + // Both ratios are settled on Form 1 - Part B is section 18, Part C section 24 - so + // the part is named rather than a separate return that does not exist. if ("SLR_INVESTMENTS".equals(role)) { - ROLE_USAGE.put(role, "Form 9 (SLR) — maintained"); + ROLE_USAGE.put(role, "Form 1 Part C (SLR) — maintained"); } else if ("DEMAND_TIME_LIABILITIES".equals(role)) { - ROLE_USAGE.put(role, "Form 1 & Form 9 — base"); + ROLE_USAGE.put(role, "Form 1 — base for both ratios"); } else { - ROLE_USAGE.put(role, "Form 1 (CRR) — maintained"); + ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained"); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java index b8e2c98a7..2009a31a8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -1084,6 +1084,15 @@ public class RbiReserveReturnApiResource { */ private static LocalDate requirePastOrPresent(final LocalDate date, final String what) { final LocalDate today = DateUtils.getBusinessLocalDate(); + // A year the ledger could not hold. A date field hands over whatever has been typed so far, + // so a half-typed year arrives as a real date - "0026-09-30" parses cleanly - and the return + // then reads a period no bank has entries for and reports every line nil. + if (date.getYear() < 1900) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.year.incomplete", + what + " (" + date + ") has a year below 1900, which looks like a date still being " + + "typed rather than a period to report.", + what, date.toString()); + } if (date.isAfter(today)) { throw new PlatformDataIntegrityException("error.msg.rbireservereturn.future.date", what + " (" + date + ") is in the future; the bank's business date is " + today @@ -1130,6 +1139,8 @@ public class RbiReserveReturnApiResource { public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam, @QueryParam("firstColumnDate") final String firstColumnDateParam, @QueryParam("secondColumnDate") final String secondColumnDateParam, + @QueryParam("firstBaseDate") final String firstBaseDateParam, + @QueryParam("secondBaseDate") final String secondBaseDateParam, @QueryParam("officeId") final Long officeId) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); @@ -1174,15 +1185,51 @@ public class RbiReserveReturnApiResource { } // The third column is the base the current fortnight's ratios are charged on - the last - // Friday of the second preceding fortnight - not the reporting date. + // matching close one month earlier - not the reporting date. // // It was the reporting date. Since the return is now made up to the second column, that // made the third column a copy of the second: two identically dated columns carrying // identical figures, which reads as a filled form and says nothing. The base belongs on the // face of the form because every required amount below is computed from it, and a reviewer // cannot check a requirement whose base is not stated. - final LocalDate currentBase = formOneBaseDate(currentClose); - final List columnDates = List.of(previousClose, currentClose, currentBase); + // Each close has its own base, and either can be given outright. + // + // Derived, the base is the matching close one month earlier, which is + // what the rules fix it at. But a bank migrating onto the system, or filing a corrected + // return for a period whose base fell before its ledger starts, has a base date that the + // rule produces and the ledger cannot answer - and the ratio then reads nil against real + // liabilities. Given explicitly, the date entered is used and stated on the form, so a + // reader can see which base each column was charged on. + final LocalDate previousBase = firstBaseDateParam == null || firstBaseDateParam.isBlank() + ? formOneBaseDate(previousClose) + : requirePastOrPresent(LocalDate.parse(firstBaseDateParam), "The first column's base date"); + final LocalDate currentBase = secondBaseDateParam == null || secondBaseDateParam.isBlank() + ? formOneBaseDate(currentClose) + : requirePastOrPresent(LocalDate.parse(secondBaseDateParam), "The second column's base date"); + + // A base dated after the close it serves would charge the ratio on liabilities the bank had + // not yet taken on - the figure would be arithmetically fine and mean nothing. + if (previousBase.isAfter(previousClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close", + "The first column's base date (" + previousBase + ") falls after the close it is charged on (" + + previousClose + ").", + previousBase.toString(), previousClose.toString()); + } + if (currentBase.isAfter(currentClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close", + "The second column's base date (" + currentBase + ") falls after the close it is charged on (" + + currentClose + ").", + currentBase.toString(), currentClose.toString()); + } + + // Two columns, one per fortnight close. The base dates are not columns of their own: they + // are stated against the lines they settle, and a third column headed with one printed the + // base's own balances beside the closes', which read as a third period the bank was + // reporting rather than as the figures the ratios were charged on. + final List columnDates = List.of(previousClose, currentClose); + // One base per column, in the same order, so each column is charged on its own base rather + // than both on one. + final List baseDates = List.of(previousBase, currentBase); final Map response = new LinkedHashMap<>(); response.put("formCode", "FORM_1_STATUTORY"); @@ -1190,6 +1237,9 @@ public class RbiReserveReturnApiResource { response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966"); response.put("reportingDate", reportingDate.toString()); response.put("columns", columnDates.stream().map(LocalDate::toString).toList()); + // Stated beside the columns so a reader can see which date each ratio was charged on, + // without the base needing a column of its own. + response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList()); final Rate crr = rateInForce("CRR", reportingDate); final Rate slr = rateInForce("SLR", reportingDate); @@ -1197,10 +1247,14 @@ public class RbiReserveReturnApiResource { response.put("slrPercentage", slr == null ? null : slr.percentage); final List> parts = new ArrayList<>(); - parts.add(formOnePartA(columnDates, officeId, crr, slr)); + parts.add(formOnePartA(columnDates, baseDates, officeId, crr, slr)); response.put("partA", parts.get(0)); - response.put("partB", formOnePartB(columnDates, officeId, crr)); - response.put("partC", formOnePartC(columnDates, officeId, slr)); + response.put("partB", formOnePartB(columnDates, baseDates, officeId, crr)); + response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr)); + + // Every day of the month the closes fall in, charged on each day's own base. The form + // reports two closes; compliance turns on every day between them. + response.put("daily", formOneDaily(previousClose.withDayOfMonth(1), currentClose, officeId, crr, slr)); final List unmet = new ArrayList<>(); if (crr == null) { @@ -1219,9 +1273,8 @@ public class RbiReserveReturnApiResource { final BigDecimal baseLiabilities = formOneNetLiabilities(currentBase, officeId); if (baseLiabilities.compareTo(BigDecimal.ZERO) == 0) { unmet.add("Both ratios are charged on the net liabilities of " + currentBase - + " — the last Friday of the second preceding fortnight — and the ledger holds " - + "none on that date. The required amounts are therefore nil. They are nil " - + "because the base is nil, not because nothing is required."); + + ", and the ledger holds none on that date. The required amounts are therefore " + + "nil — nil because the base is nil, not because nothing is required."); } response.put("unmetRequirements", unmet); @@ -1229,7 +1282,7 @@ public class RbiReserveReturnApiResource { } /** Part A: the liabilities and assets the two ratios are worked out from. */ - private Map formOnePartA(final List dates, final Long officeId, final Rate crr, + private Map formOnePartA(final List dates, final List bases, final Long officeId, final Rate crr, final Rate slr) { final List> lines = new ArrayList<>(); @@ -1279,7 +1332,25 @@ public class RbiReserveReturnApiResource { for (int i = 0; i < dates.size(); i++) { iv.add(totalI.get(i).subtract(totalIII.get(i)).max(BigDecimal.ZERO).add(totalII.get(i))); } - lines.add(formOneTotal("IV. TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24", iv)); + // The form carries IV twice, and the difference between the two is the whole of how the + // ratios work. (a) is this column's own liabilities - what the bank owed on the close being + // reported. (b) is the base: the same figure read at the earlier close, and the only one the + // requirement is actually charged on. A form showing one IV reads as though the bank had to + // hold a ratio of today's liabilities, which is not what either section says. + lines.add(formOneTotal( + "IV. a) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [CURRENT FORTNIGHT]", iv)); + final List ivBase = new ArrayList<>(); + final List ivBaseDates = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + final LocalDate base = baseFor(bases, dates, i); + ivBaseDates.add(base.toString()); + ivBase.add(formOneNetLiabilities(base, officeId)); + } + final Map ivBaseRow = formOneTotal( + "IV. b) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [SECOND PRECEDING FORTNIGHT]", + ivBase); + ivBaseRow.put("baseDates", ivBaseDates); + lines.add(ivBaseRow); final List v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0); @@ -1311,37 +1382,25 @@ public class RbiReserveReturnApiResource { } lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii)); - // The liabilities the ratios are charged on: the last Friday of the second preceding - // fortnight, read per column rather than once, so each column states its own base. - final List baseDateDtl = new ArrayList<>(); - final List baseDates = new ArrayList<>(); - for (final LocalDate date : dates) { - final LocalDate base = formOneBaseDate(date); - baseDates.add(base.toString()); - baseDateDtl.add(formOneNetLiabilities(base, officeId)); - } - final Map baseRow = formOneTotal( - "DEMAND AND TIME LIABILITIES OF THE BASE DATE — last Friday of the second preceding fortnight", - baseDateDtl); - baseRow.put("baseDates", baseDates); - lines.add(baseRow); - + // The base liabilities are IV(b) above - it used to be repeated as a row of its own at the + // foot of the part, which put the same figure on the form twice under two different names. final Map part = new LinkedHashMap<>(); part.put("title", "PART A"); part.put("lines", lines); - part.put("baseDates", baseDates); - part.put("baseDateLiabilities", baseDateDtl); + part.put("baseDates", ivBaseDates); + part.put("baseDateLiabilities", ivBase); return part; } /** Part B: compliance with section 18 — the cash reserve. */ - private Map formOnePartB(final List dates, final Long officeId, final Rate crr) { + private Map formOnePartB(final List dates, final List bases, final Long officeId, final Rate crr) { final List> lines = new ArrayList<>(); final List required = new ArrayList<>(); final List maintained = new ArrayList<>(); - for (final LocalDate date : dates) { - final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId); + for (int i = 0; i < dates.size(); i++) { + final LocalDate date = dates.get(i); + final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId); required.add(crr == null ? BigDecimal.ZERO : base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP)); // X = V + VI + VIII, the form's own definition. @@ -1354,8 +1413,12 @@ public class RbiReserveReturnApiResource { maintained.add(cash.add(currentAccounts).add(netCurrent)); } - lines.add(formOneTotal("IX. " + (crr == null ? "CRR" : crr.percentage + "%") - + " of IV as on the last Friday of the second preceding fortnight", required)); + // The rate is named against IV(b), not IV, because that is the figure it is charged on. The + // template's own wording says "Last Friday of the Second Preceding fortnight"; this bank's + // base rule is the matching close of the previous month, so the line says IV(b) and leaves + // the base date itself to the column heading rather than claiming a Friday it did not use. + lines.add(formOneTotal("IX. CRR REQUIRED TO BE MAINTAINED — " + (crr == null ? "rate not set" : crr.percentage + "% of IV(b)"), + required)); lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained)); final Map part = new LinkedHashMap<>(); @@ -1368,15 +1431,16 @@ public class RbiReserveReturnApiResource { } /** Part C: compliance with section 24 — the liquid assets. */ - private Map formOnePartC(final List dates, final Long officeId, final Rate slr) { + private Map formOnePartC(final List dates, final List bases, final Long officeId, final Rate slr) { final List> lines = new ArrayList<>(); final List required = new ArrayList<>(); final List cashAndOther = new ArrayList<>(); final List gold = new ArrayList<>(); final List securities = new ArrayList<>(); - for (final LocalDate date : dates) { - final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId); + for (int i = 0; i < dates.size(); i++) { + final LocalDate date = dates.get(i); + final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId); final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO : base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); required.add(slrRequired); @@ -1395,8 +1459,9 @@ public class RbiReserveReturnApiResource { securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount); } - lines.add(formOneTotal("XI. " + (slr == null ? "SLR" : slr.percentage + "%") - + " of IV as on the last Friday of the second preceding fortnight", required)); + // Charged on IV(b) like the cash reserve above, and named the same way. + lines.add(formOneTotal("XI. SLR REQUIRED TO BE MAINTAINED — " + (slr == null ? "rate not set" : slr.percentage + "% of IV(b)"), + required)); lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED")); lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1)); lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1)); @@ -1413,6 +1478,115 @@ public class RbiReserveReturnApiResource { return part; } + /** + * Every day of the period the form covers, with what each ratio required that day and what was + * held against it. + * + *

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

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

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

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

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

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

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

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

+ * The customer here is the deductee: a minor's deposit is listed under the guardian and a + * joint deposit under its primary holder, because that is whose PAN the tax is deducted against. */ public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, - String branchName, String form15Type, List accounts) implements Serializable { + String branchName, List declarations, List accounts) implements Serializable { /** - * One deposit account and the interest posted to it. + * One deposit account, the interest posted to it and the TDS actually deducted from it. * * @param depositTypeCode * Fineract's own code - {@code depositAccountType.fixedDeposit}, * {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether * the charge applies at all, so it is sent as the platform states it rather than as * a flag this report worked out for itself. + * @param clientId + * the deductee, which is the account holder unless the account is a minor's or joint + * @param holderClientId + * the account's own holder, shown when it differs from the deductee */ - public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus, - Long clientId, LocalDate openedOn, List credits) implements Serializable {} + public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId, + String holderName, String accountStatus, LocalDate openedOn, List credits, + List deductions) implements Serializable {} - /** One interest posting: the date it was credited and the amount. */ - public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo) - implements Serializable {} + /** One interest posting, or one TDS deduction: the date and the amount. */ + public record TdsInterestCreditData(LocalDate date, BigDecimal amount, String accountNo) implements Serializable {} } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java new file mode 100644 index 000000000..be76fd39d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java @@ -0,0 +1,389 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.service; + +import jakarta.persistence.EntityManager; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.portfolio.account.domain.AccountTransferType; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * TDS on FD/RD interest, tracked and deducted where the interest is received. + * + *

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

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

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

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

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

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

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

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

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

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

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

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