Compare commits

...
Author SHA1 Message Date
keshav cca4ae583f Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-10-07 10:14:13 +05:30
keshav 99a7e04872 feat: updates for tds services, rbi reserve config, and cashier api resources 2026-10-07 10:14:04 +05:30
niteshandClaude Opus 5 643427bd1a fix(shares): correct share transaction lookup and validate NONE settlements
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
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) <noreply@anthropic.com>
2026-10-06 19:03:51 +05:30
29 changed files with 1925 additions and 193 deletions
@@ -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);
}
}
@@ -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
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Long> registerIds = jdbcTemplate.queryForList(
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
@@ -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"));
}
}
@@ -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<Map<String, Object>> 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<Long> 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<String, Object> 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;
@@ -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);
@@ -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<Long> registerIds = jdbcTemplate.query("""
@@ -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\"}";
@@ -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);
@@ -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);
@@ -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");
}
}
}
@@ -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<LocalDate> 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<LocalDate> 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<LocalDate> baseDates = List.of(previousBase, currentBase);
final Map<String, Object> 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<Map<String, Object>> 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<String> 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<String, Object> formOnePartA(final List<LocalDate> dates, final Long officeId, final Rate crr,
private Map<String, Object> formOnePartA(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate crr,
final Rate slr) {
final List<Map<String, Object>> 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<BigDecimal> ivBase = new ArrayList<>();
final List<String> 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<String, Object> 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<BigDecimal> 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<BigDecimal> baseDateDtl = new ArrayList<>();
final List<String> baseDates = new ArrayList<>();
for (final LocalDate date : dates) {
final LocalDate base = formOneBaseDate(date);
baseDates.add(base.toString());
baseDateDtl.add(formOneNetLiabilities(base, officeId));
}
final Map<String, Object> 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<String, Object> 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<String, Object> formOnePartB(final List<LocalDate> dates, final Long officeId, final Rate crr) {
private Map<String, Object> formOnePartB(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate crr) {
final List<Map<String, Object>> lines = new ArrayList<>();
final List<BigDecimal> required = new ArrayList<>();
final List<BigDecimal> 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<String, Object> part = new LinkedHashMap<>();
@@ -1368,15 +1431,16 @@ public class RbiReserveReturnApiResource {
}
/** Part C: compliance with section 24 — the liquid assets. */
private Map<String, Object> formOnePartC(final List<LocalDate> dates, final Long officeId, final Rate slr) {
private Map<String, Object> formOnePartC(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate slr) {
final List<Map<String, Object>> lines = new ArrayList<>();
final List<BigDecimal> required = new ArrayList<>();
final List<BigDecimal> cashAndOther = new ArrayList<>();
final List<BigDecimal> gold = new ArrayList<>();
final List<BigDecimal> 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.
*
* <p>
* 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.
*
* <p>
* 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<String, Object> formOneDaily(final LocalDate from, final LocalDate to, final Long officeId,
final Rate crr, final Rate slr) {
final List<Map<String, Object>> 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<String, Object> 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<String, Object> 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.
*
* <p>
* 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<LocalDate> bases, final List<LocalDate> 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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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<LocalDate> 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<LocalDate> columnDates = List.of(previousClose, currentClose);
final List<LocalDate> baseDates = List.of(formOneBaseDate(previousClose), base);
final Map<String, Object> 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<String> 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);
@@ -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
@@ -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 = ?
@@ -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<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
@@ -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;
@@ -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<TdsDeducteeData> deductees = this.readPlatformService
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId);
return this.toApiJsonSerializer.serialize(deductees);
}
@@ -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.
*
* <p>
* 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<Object> 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<Map<String, Object>> 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<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Long> 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<Map<String, Object>> 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<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Long> 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.");
}
}
}
@@ -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 {}
@@ -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.
*
* <p>
* 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.
*
* <p>
* The customer here is the <em>deductee</em>: 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<TdsAccountData> accounts) implements Serializable {
String branchName, List<TdsDeclarationData> declarations, List<TdsAccountData> 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<TdsInterestCreditData> credits) implements Serializable {}
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId,
String holderName, String accountStatus, LocalDate openedOn, List<TdsInterestCreditData> credits,
List<TdsInterestCreditData> 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 {}
}
@@ -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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* The rules, all for one financial year and one deductee:
* <ol>
* <li>The FD/RD interest received, across every branch, is added up. The savings account's own interest
* is not - it is outside the charge.</li>
* <li>A declaration shields the interest received from the day it is accepted. Interest received
* before it stays liable.</li>
* <li>Nothing is deducted while the total stays within the threshold (senior or general).</li>
* <li>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.</li>
* <li>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.</li>
* </ol>
*
* <p>
* 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<Long> existingIds = new HashSet<>(to.findExistingTransactionIds());
final Set<Long> 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.
*
* <p>
* 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<Long> 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<Map<String, Object>> 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<String, Object> cfg = cfgRows.get(0);
final List<LocalDate> 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<String, Object> 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<String, Object> 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.
*
* <p>
* 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<Long> 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<Long> 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;
}
}
@@ -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<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear, Long clientId);
}
@@ -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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
public List<TdsDeducteeData> 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<Long, Deductee> 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<Long, List<TdsDeclarationData>> declarations = declarationsFor(TdsSql.fyLabel(financialYear));
final List<TdsDeducteeData> result = new ArrayList<>();
for (final Deductee d : byClient.values()) {
final List<TdsAccountData> 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<Long, List<TdsDeclarationData>> declarationsFor(final String fy) {
final Map<Long, List<TdsDeclarationData>> 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<Long, Account> 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<TdsInterestCreditData> credits = new ArrayList<>();
private final List<TdsInterestCreditData> 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;
}
}
@@ -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.
*
* <ul>
* <li>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.</li>
* <li>A joint account follows the holder flagged primary - one deposit, one deductee.</li>
* <li>Anything else follows the account holder.</li>
* </ul>
*
* 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);
}
}
@@ -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}
@@ -165,4 +165,6 @@
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0592_tds_declarations_and_challans.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_tds_interest_receipts_and_deductions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,217 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
The two records TDS on deposit interest was missing.
m_tds_declaration - a 15G / 15H / Form 121 declaration, one per customer per financial year.
The KYC record only ever carried the form type and a year, so a declaration given in
September shielded interest back to April, and nothing marked a new year as needing a new
form. A declaration operates from the day the branch accepts it, for that year only, so both
the year and the acceptance date are kept here.
m_tds_challan - tax actually remitted to the Government. The remittance report used to make
challan numbers, BSR codes and deposit dates up; every row here is one the bank entered,
posted against the TDS payable account.
Constraints sit inside createTable: a separate tableExists-guarded changeSet would see the
table already present and skip them.
-->
<changeSet author="fineract" id="0592-1-create-tds-declaration">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_tds_declaration"/></not>
</preConditions>
<createTable tableName="m_tds_declaration">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_declaration_client"
referencedTableName="m_client" referencedColumnNames="id"/>
</column>
<!-- '2026-27'. -->
<column name="fy_label" type="VARCHAR(7)">
<constraints nullable="false"/>
</column>
<!-- 15G, 15H or 121. -->
<column name="form_type" type="VARCHAR(5)">
<constraints nullable="false"/>
</column>
<column name="uin" type="VARCHAR(30)"/>
<!-- The day the branch accepted it. Interest credited before this date is not shielded. -->
<column name="accepted_on" type="DATE">
<constraints nullable="false"/>
</column>
<column name="estimated_income" type="DECIMAL(19,6)"/>
<column name="is_revoked" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="revoked_on" type="DATE"/>
<column name="revoke_reason" type="VARCHAR(500)"/>
<column name="created_by" type="BIGINT"/>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="revoked_by" type="BIGINT"/>
</createTable>
<createIndex tableName="m_tds_declaration" indexName="idx_tds_declaration_client_fy">
<column name="client_id"/>
<column name="fy_label"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0592-2-create-tds-challan">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_tds_challan"/></not>
</preConditions>
<createTable tableName="m_tds_challan">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="fy_label" type="VARCHAR(7)">
<constraints nullable="false"/>
</column>
<!-- Month the tax was deducted in, '2026-06'. The due date follows from it. -->
<column name="deduction_month" type="VARCHAR(7)">
<constraints nullable="false"/>
</column>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="challan_no" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="bsr_code" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="deposited_on" type="DATE">
<constraints nullable="false"/>
</column>
<!-- Bank or cash account the tax was paid out of: Dr TDS payable / Cr this account. -->
<column name="paid_from_gl_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_challan_paid_from"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
</column>
<column name="office_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_challan_office"
referencedTableName="m_office" referencedColumnNames="id"/>
</column>
<!-- acc_gl_journal_entry.transaction_id of the remittance entry. -->
<column name="journal_transaction_id" type="VARCHAR(50)"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT"/>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
</createTable>
<createIndex tableName="m_tds_challan" indexName="idx_tds_challan_fy">
<column name="fy_label"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0592-3-tds-declaration-challan-permissions">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_permission"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission
WHERE code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION', 'CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN')
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_TDSDECLARATION"/>
<column name="entity_name" value="TDSDECLARATION"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_TDSDECLARATION"/>
<column name="entity_name" value="TDSDECLARATION"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="CREATE_TDSCHALLAN"/>
<column name="entity_name" value="TDSCHALLAN"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_TDSCHALLAN"/>
<column name="entity_name" value="TDSCHALLAN"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0592-4-grant-tds-declaration-challan-permissions">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_role_permission"/>
<sqlCheck expectedResult="0">
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')
</sqlCheck>
</and>
</preConditions>
<!-- Declarations are taken at the counter, so every role records them. Challans are a Head
Office remittance, so only the roles that hold the TDS configuration post them. -->
<sql>
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');
</sql>
</changeSet>
<changeSet author="fineract" id="0592-5-carry-over-kyc-declarations">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_tds_declaration"/>
</preConditions>
<!-- Only KYC rows that were actually switched on and name a year. The acceptance date was never
recorded, so the year's first day stands in, which is what the screens assumed until now. -->
<sql>
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;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
TDS on FD/RD interest is tracked and deducted where the interest is received: the customer's
savings account. The FD and RD accounts themselves are left alone.
m_tds_interest_receipt - one row per credit into a savings account that carried FD/RD interest:
the periodic interest sweep in full, or the interest part of a maturity / closure payout. This
is the running total the threshold is tested against. A savings credit is otherwise just a
deposit, so without this record there is no telling interest from the customer's own money.
m_tds_deduction - one row per TDS debit on a savings account, tied to the deductee, so the
tax already taken this year is known per customer.
A row only counts while its savings transaction stands - reversing the transaction takes it out.
-->
<changeSet author="fineract" id="0593-1-create-tds-interest-receipt">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_tds_interest_receipt"/></not>
</preConditions>
<createTable tableName="m_tds_interest_receipt">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- Whose PAN the interest is taxed against: the FD/RD's holder, guardian or primary joint holder. -->
<column name="deductee_client_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_receipt_client"
referencedTableName="m_client" referencedColumnNames="id"/>
</column>
<!-- The savings account the interest was credited to. -->
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_receipt_savings"
referencedTableName="m_savings_account" referencedColumnNames="id"/>
</column>
<column name="savings_transaction_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_receipt_txn"
referencedTableName="m_savings_account_transaction" referencedColumnNames="id"/>
</column>
<!-- The FD or RD the interest came from. -->
<column name="source_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_receipt_source"
referencedTableName="m_savings_account" referencedColumnNames="id"/>
</column>
<!-- INTEREST: a periodic interest sweep. PAYOUT: the interest part of a maturity or closure payout. -->
<column name="kind" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="fy_label" type="VARCHAR(7)">
<constraints nullable="false"/>
</column>
<column name="received_on" type="DATE">
<constraints nullable="false"/>
</column>
<column name="interest_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
</createTable>
<createIndex tableName="m_tds_interest_receipt" indexName="idx_tds_receipt_deductee_fy">
<column name="deductee_client_id"/>
<column name="fy_label"/>
</createIndex>
<createIndex tableName="m_tds_interest_receipt" indexName="idx_tds_receipt_source">
<column name="source_account_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0593-2-create-tds-deduction">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_tds_deduction"/></not>
</preConditions>
<createTable tableName="m_tds_deduction">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="deductee_client_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_deduction_client"
referencedTableName="m_client" referencedColumnNames="id"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_deduction_savings"
referencedTableName="m_savings_account" referencedColumnNames="id"/>
</column>
<!-- The withhold-tax debit on the savings account. -->
<column name="savings_transaction_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_deduction_txn"
referencedTableName="m_savings_account_transaction" referencedColumnNames="id"/>
</column>
<!-- The FD or RD whose interest triggered it. -->
<column name="source_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_deduction_source"
referencedTableName="m_savings_account" referencedColumnNames="id"/>
</column>
<column name="receipt_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_deduction_receipt"
referencedTableName="m_tds_interest_receipt" referencedColumnNames="id"/>
</column>
<column name="fy_label" type="VARCHAR(7)">
<constraints nullable="false"/>
</column>
<column name="deducted_on" type="DATE">
<constraints nullable="false"/>
</column>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="rate_percentage" type="DECIMAL(9,4)">
<constraints nullable="false"/>
</column>
<!-- Interest the year's liability was worked out on, at the time. -->
<column name="interest_base" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
</createTable>
<createIndex tableName="m_tds_deduction" indexName="idx_tds_deduction_deductee_fy">
<column name="deductee_client_id"/>
<column name="fy_label"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -795,6 +795,29 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return false;
}
/**
* Deducts TDS from this savings account on FD/RD interest that has just been credited to it.
*
* <p>
* 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<TaxComponent, BigDecimal> 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<SavingsAccountTransaction> trans = getTransactions();