Compare commits
2
Commits
643427bd1a
...
cca4ae583f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca4ae583f | ||
|
|
99a7e04872 |
+15
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+20
-5
@@ -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 = ?",
|
||||
|
||||
+10
-9
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -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;
|
||||
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+3
-3
@@ -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("""
|
||||
|
||||
+1
-1
@@ -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\"}";
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+5
-3
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+260
-55
@@ -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);
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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 = ?
|
||||
|
||||
+30
@@ -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),
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+490
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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 {}
|
||||
+18
-9
@@ -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 {}
|
||||
}
|
||||
|
||||
+389
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
|
||||
+122
-65
@@ -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>
|
||||
|
||||
+217
@@ -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>
|
||||
+143
@@ -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>
|
||||
+23
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user