From 6c7c0f84451a8c5aa2c6e5c6e12f2e410b53491f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 6 Oct 2026 12:42:12 +0530 Subject: [PATCH 1/4] build: freeze the default (non-release/maintenance branch) jar version git-versioning's rev{} fallback previously derived the SNAPSHOT version from the nearest reachable git tag (major.(minor+1).0-SNAPSHOT), so the built jar's name drifted as tags moved. Pins it to a fixed 1.15.0-SNAPSHOT instead so the jar name stays predictable across builds on dev-latest/feature branches. release/*.*.* and maintenance/*.* branches still version off their own tags, unchanged. Co-Authored-By: Claude Sonnet 5 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index fc2ca9941..c2dc2e63f 100644 --- a/build.gradle +++ b/build.gradle @@ -154,7 +154,7 @@ gitVersioning.apply { } } rev { - version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT' + version = '1.15.0-SNAPSHOT' } } From 643427bd1abc5791b174c2e4d1764a132f760134 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 6 Oct 2026 19:03:51 +0530 Subject: [PATCH 2/4] fix(shares): correct share transaction lookup and validate NONE settlements redemptionValueOf() read from "m_share_account_transaction" with a "share_account_id" column. The table is m_share_account_transactions and the column is account_id, so every share withdrawal approval failed with a BadSqlGrammarException on the Share Sundry balance guard, before any posting was attempted. The lookup now also filters on is_active, matching ShareAdvanceCollectionApiResource.shareValueOf(), so a reversed purchase cannot price a redemption. validate() branched on "GL" versus everything else, but approve() handles a third settlement mode: submit() downgrades a request to NONE when the clerk names the product's own Share Sundry GL as the destination, and that mode posts Dr Share Capital / Cr Share Sundry and stops. Validating it as a savings settlement reported a missing fund source mapping that no posting in this mode ever reads, so a correct request showed a failed check. NONE is now reported as not applicable for both the linked-savings and the fund-source checks. Co-Authored-By: Claude Opus 5 (1M context) --- ...lerkShareWithdrawalRequestApiResource.java | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index da1a79dc7..e7dedf034 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -451,8 +451,18 @@ public class ClerkShareWithdrawalRequestApiResource { // not-applicable rather than failed for a GL request — a share account with no linked // savings is a perfectly valid GL-settlement case. final boolean glSettlementMode = "GL".equals(record.get("settlement_mode")); - checks.add(glSettlementMode - ? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer") + // 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who + // names the product's own Share Sundry GL as the destination is saying "stop here", so the + // request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry + // and nothing further. Like a GL settlement it never reaches a savings account, so the + // savings-side checks below do not apply to it either — running them reports a missing fund + // source mapping that no posting in this mode is ever going to read. + final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode")); + final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement; + checks.add(noSavingsLeg + ? checkResult("Linked savings account is Active", true, sundryOnlySettlement + ? "Not applicable — the redemption rests in Share Sundry" + : "Not applicable — settlement is a GL book-transfer") : checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300, savingsAccountId == null ? "No linked savings account configured" : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus))); @@ -541,6 +551,9 @@ public class ClerkShareWithdrawalRequestApiResource { final boolean settlementGlPresent = glAccountIdObj != null; checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent, settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing")); + } else if (sundryOnlySettlement) { + checks.add(checkResult("Settlement destination recorded", true, + "Share Sundry — the redemption stops there, so no fund source mapping is read")); } else { boolean glMappingOk = false; String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check"; @@ -992,8 +1005,8 @@ public class ClerkShareWithdrawalRequestApiResource { // The purchase that issued the holding - type 500. Its unit price is what the member paid. final BigDecimal unitPrice = jdbcTemplate .queryForList(""" - SELECT unit_price FROM m_share_account_transaction - WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL + SELECT unit_price FROM m_share_account_transactions + WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL ORDER BY id DESC LIMIT 1 """, BigDecimal.class, shareAccountId) .stream().findFirst().orElse(null); From ac7873a51e10aa7ecda3267631108dd1134beacc Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 6 Oct 2026 22:40:12 +0530 Subject: [PATCH 3/4] feat(shares): add preview-validate endpoint, fix document attachment 500 ClerkShareWithdrawalRequestApiResource: extracted validate(Long)'s checklist logic into buildShareWithdrawalChecks(Map) and added a new POST /preview-validate endpoint that runs it against a draft submission (same fields submit() accepts, same Sundry-downgrade rule applied first) instead of an already-persisted request. Lets the clerk see the exact checklist a manager will see on approval before the request is even submitted, with zero risk of the two checklists drifting apart, since both now call the same method. The one check validate() runs that a draft cannot -- "Maker and Checker are different users" -- is reported as a fixed reminder instead of silently dropped, since the clerk previewing this is necessarily the maker and no checker has been chosen yet. DocumentReadPlatformServiceImpl.retrieveDocumentContent(): a document/signature whose m_document row survives but whose underlying file is missing from the content store (the usual cause is a database copied between environments without its file store) was throwing an uncaught ContentStoreException, surfacing as a raw 500 on every fetch. Now caught and converted to the same DocumentNotFoundException (404) ImageReadPlatformServiceImpl already uses for the identical situation on client photos, so the frontend's existing graceful-fallback handling for a missing attachment applies here too. Co-Authored-By: Claude Opus 5 (1M context) --- .../DocumentReadPlatformServiceImpl.java | 43 ++++++- ...lerkShareWithdrawalRequestApiResource.java | 106 ++++++++++++++++-- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentReadPlatformServiceImpl.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentReadPlatformServiceImpl.java index 2437d810f..d83b0a795 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentReadPlatformServiceImpl.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentReadPlatformServiceImpl.java @@ -18,20 +18,29 @@ */ package org.apache.fineract.infrastructure.documentmanagement.service; +import java.io.FileNotFoundException; +import java.nio.file.NoSuchFileException; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData; import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository; +import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException; import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException; import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService { + /** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */ + private static final int MAX_CAUSE_DEPTH = 10; + private final ContentStoreService storeService; private final DocumentRepository documentRepository; private final DocumentMapper documentMapper; @@ -52,9 +61,39 @@ public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformServ public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) { final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId) .orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId)); - final var is = storeService.download(doc.getLocation()); + try { + final var is = storeService.download(doc.getLocation()); + return documentMapper.map(doc, is); + } catch (final AbstractPlatformDomainRuleException e) { + throw e; + } catch (final Exception e) { + if (isMissingFromContentStore(e)) { + // The m_document row survives but the file it points at does not — the usual cause is + // a database copied between environments without its file store (see the identical + // guard in ImageReadPlatformServiceImpl). For the caller that is simply "this document + // has no content", not a server fault, so report it the same way as a missing document + // instead of letting a raw ContentStoreException surface as a 500. + log.warn("Document {} for {} {} is recorded but missing from the content store; treating it as not found", documentId, + entityType, entityId); + throw new DocumentNotFoundException(entityType, entityId, documentId); + } + throw new DocumentInvalidRequestException(e); + } + } - return documentMapper.map(doc, is); + /** + * Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the + * way up (the file store rethrows as ContentStoreException). + */ + private boolean isMissingFromContentStore(final Throwable failure) { + Throwable cause = failure; + for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) { + return true; + } + cause = cause.getCause(); + } + return false; } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index e7dedf034..2b4bb563e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -390,6 +390,101 @@ public class ClerkShareWithdrawalRequestApiResource { public String validate(@PathParam("id") final Long id) { final AppUser currentUser = context.authenticatedUser(); final Map record = loadRecord(id); + final List> checks = buildShareWithdrawalChecks(record); + + final Long makerId = ((Number) record.get("clerk_id")).longValue(); + final boolean makerCheckerOk = !makerId.equals(currentUser.getId()); + checks.add(checkResult("Maker and Checker are different users", makerCheckerOk, + makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it")); + + final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass"))); + final JsonObject res = new JsonObject(); + res.addProperty("allPassed", allPassed); + res.add("checks", gson.toJsonTree(checks)); + return gson.toJson(res); + } + + /** + * Lets the clerk see the same checklist the approver will see, before the request is even + * submitted — so a request that is going to fail a check (a missing GL mapping, an unverified + * KYC, an open AML alert) is caught while someone can still fix it, not after a manager has + * already spent time reviewing it. Takes the same fields {@link #submit(String)} does and + * applies the identical Sundry-downgrade rule, so what this previews is exactly what would be + * stored and exactly what {@link #validate(Long)} would later report on the saved request — + * never a second, independently-drifting copy of the rules. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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