Compare commits

..
2 Commits
Author SHA1 Message Date
anshulandClaude Opus 5.5 2569e0f836 feat(investment): interest schedule API for Interest Entry
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Add GET /v1/investment/purchases/{id}/interest-schedule. For a holding it
lists every half-yearly coupon and every 31 Mar / 30 Sep closing provision
from purchase to maturity, worked out on the face value still held:
provision = interest earned since the last coupon (30/360), and the coupon
clears what is already booked (purchased accrued interest or the earlier
provision). Lines up to the as-on date are marked due. Calculation only;
nothing is posted.

The day-count and interest arithmetic move into InvestmentInterestCalculator,
shared with Purchase Entry, and are covered by unit tests using Jitendra
Sir's worked example. Duplicate-code errors on the masters now keep the
original exception as the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:45:41 +05:30
anshulandClaude Opus 5.5 81b5850b62 feat(investment): Bank/Party Master, Script Master and Purchase Entry backend
Treasury Investment module, first slice, following the bank's existing CBS
Investment screens:

- Bank / Party Master (m_investment_party): auto party code PTY-nnn, address,
  bank type, GL A/c, RBI code, exposure limit, LEI, counterparty bank details.
- Script Master (m_investment_script): auto script code by bond type
  (G00001, B00001, ...), SLR type (GOI / STATE_GOVT / TREASURY / NON_SLR),
  GL A/c, ISIN, interest rate, issue / maturity dates, issuer details.
- Purchase Entry (m_investment_purchase): one deal = one holding. FY deal
  number (e.g. 202627000001), auto cert no, settlement date from T+n.
  Units, book value, premium/discount, accrued interest (30/360 up to the
  deal date), settlement amount and YTM are computed by the backend.

APIs: /v1/investment/parties, /v1/investment/scripts, /v1/investment/purchases,
all through the command pipeline. Permissions READ/CREATE/UPDATE/DELETE plus
_CHECKER rows are seeded with maker-checker off and granted to roles that
hold MENU_INVESTMENT. Mapped GLs must be enabled ASSET detail accounts.
No GL entry is posted by a purchase yet.

Migrations: 0593_investment_masters, 0594_investment_purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:35:43 +05:30
125 changed files with 5091 additions and 6447 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ gitVersioning.apply {
}
}
rev {
version = '1.15.0-SNAPSHOT'
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
}
}
@@ -163,7 +163,6 @@ public class CommandSourceService {
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "RECURRINGDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
@@ -95,6 +95,62 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createInvestmentParty() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = null;
this.href = "/investment/parties";
return this;
}
public CommandWrapperBuilder updateInvestmentParty(final Long partyId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder deleteInvestmentParty(final Long partyId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder createInvestmentScript() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = null;
this.href = "/investment/scripts";
return this;
}
public CommandWrapperBuilder updateInvestmentScript(final Long scriptId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = scriptId;
this.href = "/investment/scripts/" + scriptId;
return this;
}
public CommandWrapperBuilder createInvestmentPurchase() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = null;
this.href = "/investment/purchases";
return this;
}
public CommandWrapperBuilder deleteInvestmentPurchase(final Long purchaseId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId;
return this;
}
public CommandWrapperBuilder createChequeStockLot() {
this.actionName = "CREATE";
this.entityName = "CHEQUESTOCKLOT";
@@ -3157,15 +3213,6 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder extendTenureRecurringDepositAccount(final Long accountId) {
this.actionName = "EXTENDTENURE";
this.entityName = "RECURRINGDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/recurringdepositaccounts/" + accountId + "?command=extendTenure";
return this;
}
public CommandWrapperBuilder prematureCloseRecurringDepositAccount(final Long accountId) {
this.actionName = "PREMATURECLOSE";
this.entityName = "RECURRINGDEPOSITACCOUNT";
@@ -75,8 +75,6 @@ public enum JobName {
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
; //
private final String name;
@@ -432,10 +432,6 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("prematureWithdrawalOption");
recurringDepositRequestData.add("tenureFlexibility");
recurringDepositRequestData.add("lienMarked");
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add("missedInstallmentGraceDays");
recurringDepositRequestData.add("missedInstallmentPenaltyType");
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
recurringDepositRequestData.add("withdrawalFeeForTransfers");
recurringDepositRequestData.add("enableLockinPeriod");
recurringDepositRequestData.add("maturityInstructions");
@@ -495,10 +491,6 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("prematureWithdrawalOption");
recurringDepositRequestData.add("tenureFlexibility");
recurringDepositRequestData.add("lienMarked");
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add("missedInstallmentGraceDays");
recurringDepositRequestData.add("missedInstallmentPenaltyType");
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
recurringDepositRequestData.add("maturityInstructions");
recurringDepositRequestData.add("creditMaturityProceeds");
recurringDepositRequestData.add("penaltyGracePeriodMissedInstallments");
@@ -656,8 +648,6 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add(SavingsApiConstants.balanceSwapOptionParamName);
recurringDepositRequestData.add(SavingsApiConstants.msmeParamName);
recurringDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName);
// Lien Marked: how much of this deposit may be given as loan, capped by its product's percentage.
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add(isSeniorCitizenParamName);
recurringDepositRequestData.add(seniorCitizenInterestRateParamName);
return recurringDepositRequestData;
@@ -234,20 +234,6 @@ public final class SavingsAccountData implements Serializable {
return this.transferInterestToLinkedAccount;
}
/**
* True for a recurring deposit opened under a product whose Interest Calculation Type is "Simple": its interest
* never compounds. Transient because it is only read by the batch interest-posting engine.
*/
private transient boolean simpleInterest;
public void setSimpleInterest(final boolean simpleInterest) {
this.simpleInterest = simpleInterest;
}
public boolean isSimpleInterest() {
return this.simpleInterest;
}
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
@@ -180,12 +180,6 @@ public final class SavingsHelper {
immediateWithdrawalOfInterest);
}
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest, final boolean simpleInterest) {
return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil,
immediateWithdrawalOfInterest, simpleInterest);
}
public Collection<Long> fetchPostInterestTransactionIds(Long accountId) {
return this.accountTransfersReadPlatformService.fetchPostInterestTransactionIds(accountId);
}
@@ -40,18 +40,6 @@ public class CompoundInterestHelper {
*/
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate lockUntil, Boolean interestTransferEnabled) {
return calculateInterestForAllPostingPeriods(currency, allPeriods, lockUntil, interestTransferEnabled, false);
}
/**
* @param simpleInterest
* - when true, earned interest is never added back to the balance it is earned on, so every period
* earns on the principal alone (a recurring deposit whose product's Interest Calculation Type is
* "Simple"). Compounding and posting periods still decide when interest is recognised and posted;
* they just stop changing how much is earned.
*/
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate lockUntil, Boolean interestTransferEnabled, final boolean simpleInterest) {
// sum up the 'rounded' values that are posted each posting period
Money interestEarned = Money.zero(currency);
@@ -59,8 +47,7 @@ public class CompoundInterestHelper {
// total interest earned in previous periods but not yet recognised
BigDecimal compoundedInterest = BigDecimal.ZERO;
BigDecimal unCompoundedInterest = BigDecimal.ZERO;
final CompoundInterestValues compoundInterestValues = simpleInterest ? new SimpleInterestValues()
: new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
for (final PostingPeriod postingPeriod : allPeriods) {
final BigDecimal interestEarnedThisPeriod = postingPeriod.calculateInterest(compoundInterestValues);
@@ -80,25 +67,4 @@ public class CompoundInterestHelper {
return interestEarned;
}
/**
* Interest values that refuse to compound: whatever a compounding period hands over to be compounded is dropped,
* so the balance interest is earned on never includes interest already earned.
*/
private static final class SimpleInterestValues extends CompoundInterestValues {
SimpleInterestValues() {
super(BigDecimal.ZERO, BigDecimal.ZERO);
}
@Override
public void setCompoundedInterest(final BigDecimal interestToBeCompounded) {
super.setCompoundedInterest(BigDecimal.ZERO);
}
@Override
public boolean isSimpleInterest() {
return true;
}
}
}
@@ -50,9 +50,4 @@ public class CompoundInterestValues {
this.uncompoundedInterest = BigDecimal.ZERO;
}
/** True when earned interest must never earn interest itself - not across periods, and not within one either. */
public boolean isSimpleInterest() {
return false;
}
}
@@ -94,18 +94,6 @@ public final class DailyCompoundingPeriod implements CompoundingPeriod {
return interestEarned;
}
/** Interest on each day's balance alone, none of it added to the balance - for simple interest. */
public BigDecimal calculateSimpleInterest(final BigDecimal interestRateAsFraction, final long daysInYear,
final BigDecimal minBalanceForInterestCalculation, final BigDecimal overdraftInterestRateAsFraction,
final BigDecimal minOverdraftForInterestCalculation) {
BigDecimal interestEarned = BigDecimal.ZERO;
for (final EndOfDayBalance balance : this.endOfDayBalances) {
interestEarned = interestEarned.add(balance.calculateInterestOnBalance(BigDecimal.ZERO, interestRateAsFraction, daysInYear,
minBalanceForInterestCalculation, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation));
}
return interestEarned;
}
@Override
public LocalDateInterval getPeriodInterval() {
return this.periodInterval;
@@ -301,17 +301,10 @@ public final class PostingPeriod {
// to be applied to the balanced for interest calculation
for (final CompoundingPeriod compoundingPeriod : this.compoundingPeriods) {
// A daily compounding period compounds day by day inside itself, so simple interest has to bypass it - zeroing
// what is carried between periods alone still left a month's interest earning interest within the month.
final BigDecimal interestUnrounded = compoundInterestValues.isSimpleInterest()
&& compoundingPeriod instanceof DailyCompoundingPeriod dailyPeriod
? dailyPeriod.calculateSimpleInterest(this.interestRateAsFraction, this.daysInYear,
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount())
: compoundingPeriod.calculateInterest(this.interestCompoundingType, this.interestCalculationType,
compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction, this.daysInYear,
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount());
final BigDecimal interestUnrounded = compoundingPeriod.calculateInterest(this.interestCompoundingType,
this.interestCalculationType, compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
this.daysInYear, this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount());
BigDecimal unCompoundedInterest = compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
LocalDate compoundingPeriodEndDate = compoundingPeriod.getPeriodInterval().endDate();
@@ -18,29 +18,20 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.FileNotFoundException;
import java.nio.file.NoSuchFileException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService {
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
private static final int MAX_CAUSE_DEPTH = 10;
private final ContentStoreService storeService;
private final DocumentRepository documentRepository;
private final DocumentMapper documentMapper;
@@ -61,39 +52,9 @@ public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformServ
public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) {
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
try {
final var is = storeService.download(doc.getLocation());
return documentMapper.map(doc, is);
} catch (final AbstractPlatformDomainRuleException e) {
throw e;
} catch (final Exception e) {
if (isMissingFromContentStore(e)) {
// The m_document row survives but the file it points at does not — the usual cause is
// a database copied between environments without its file store (see the identical
// guard in ImageReadPlatformServiceImpl). For the caller that is simply "this document
// has no content", not a server fault, so report it the same way as a missing document
// instead of letting a raw ContentStoreException surface as a 500.
log.warn("Document {} for {} {} is recorded but missing from the content store; treating it as not found", documentId,
entityType, entityId);
throw new DocumentNotFoundException(entityType, entityId, documentId);
}
throw new DocumentInvalidRequestException(e);
}
}
final var is = storeService.download(doc.getLocation());
/**
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
* way up (the file store rethrows as ContentStoreException).
*/
private boolean isMissingFromContentStore(final Throwable failure) {
Throwable cause = failure;
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
return true;
}
cause = cause.getCause();
}
return false;
return documentMapper.map(doc, is);
}
@Override
@@ -650,7 +650,7 @@ public class AccountingProcessorHelper {
*/
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal, final boolean payableCarriesAccrual) {
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
@@ -665,14 +665,10 @@ public class AccountingProcessorHelper {
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Recurring Deposit Interest Payable",
"Interest Payable", "Interest Payable Account")))
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
.orElse(controlAccount);
// The RD product screen saves its paid account as "Recurring Deposit Interest Paid"; without that name here an
// RD's interest skipped the paid leg even when the product had one mapped.
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Recurring Deposit Interest Paid", "Interest Paid",
"Interest Paid Account"))
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
.orElse(payableAccount);
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
@@ -710,19 +706,6 @@ public class AccountingProcessorHelper {
// debit balance would come back negative and every posting would raise a shortfall larger
// than the interest itself.
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
if (!payableCarriesAccrual) {
// Cash basis: nothing ever accrues into the payable, so whatever already sits on it - an opening balance
// brought over at migration, or another product's interest sharing the GL - is not this interest. Reading
// it as "already accrued" skipped the expense and drew that balance down instead, leaving the cost off the
// P&L. The whole amount is charged and raised, and a reversal or clawback takes it back the same way.
if (payableIsLiability) {
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
}
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId,
transactionDate, amount, isReversal);
return;
}
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
@@ -735,14 +718,6 @@ public class AccountingProcessorHelper {
shortfall, false);
}
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
}
/** Stages 2 and 3: out of the payable, through the paid account when the product names one, into the member. */
private void postPayableToMember(final Office office, final String currencyCode, final GLAccount payableAccount,
final GLAccount paidAccount, final GLAccount controlAccount, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
if (hasSeparatePaidAccount) {
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
@@ -194,7 +194,7 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
// payable-to-control pair that used to be here, which skipped interest-paid
// entirely and left the expense off the P&L.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, true);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -186,7 +186,7 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
// that names neither.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, false);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -294,14 +294,8 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), savingProductId,
CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue());
// FD and RD products switch dormancy on with "dormancyAlert" (DepositProductAssembler derives the product's
// tracking flag from it) and never send isDormancyTrackingActive, so reading only that dropped the escheat
// account the screen had asked for - the product then tracked dormancy with no escheat account mapped.
final Boolean isDormancyTrackingActive = this.fromApiJsonHelper.extractBooleanNamed(isDormancyTrackingActiveParamName, element);
final Boolean dormancyAlert = this.fromApiJsonHelper.parameterExists("dormancyAlert", element)
? this.fromApiJsonHelper.extractBooleanNamed("dormancyAlert", element)
: null;
if (Boolean.TRUE.equals(isDormancyTrackingActive) || Boolean.TRUE.equals(dormancyAlert)) {
if (null != isDormancyTrackingActive && isDormancyTrackingActive) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingProductId,
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
@@ -27,7 +27,6 @@ import static org.apache.fineract.portfolio.account.api.AccountTransfersApiConst
import com.google.common.collect.Lists;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -45,7 +44,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
@@ -76,13 +74,11 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
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
@@ -106,7 +102,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final AccountingProcessorHelper accountingProcessorHelper;
private final KycRestrictionGuard kycRestrictionGuard;
private final SavingsInterestTdsService savingsInterestTdsService;
@Transactional
@Override
@@ -152,7 +147,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
throw new DifferentCurrenciesException(fromSavingsAccount.getCurrency().getCode(),
@@ -208,7 +202,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -414,15 +407,11 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
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())) {
@@ -460,7 +449,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(accountTransferDTO, fromLoanAccount,
toSavingsAccount, deposit, loanTransaction);
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
@@ -495,7 +483,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
@@ -657,26 +644,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
return accountTransferDetails;
}
/**
* Money transferred into a recurring deposit is an installment payment, exactly like a counter deposit through
* DepositAccountDomainServiceJpa#handleRDDeposit. The generic deposit above only credits the balance, so without
* this the deposit's schedule kept showing the installment as unpaid and overdue, and its projected maturity
* amount never moved - for manual transfers, standing instructions and RD Auto Debit alike. Any other destination
* is left untouched.
*/
private void applyToRecurringDepositSchedule(final SavingsAccount toSavingsAccount, final SavingsAccountTransaction deposit) {
if (!(toSavingsAccount instanceof RecurringDepositAccount recurringDeposit)) {
return;
}
final boolean isPreMatureClosure = false;
recurringDeposit.updateDepositAmount(deposit.getAmount());
recurringDeposit.handleScheduleInstallments(deposit);
recurringDeposit.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure,
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
recurringDeposit.updateOverduePayments(DateUtils.getBusinessLocalDate());
}
private boolean isLoanToSavingsAccountTransfer(final PortfolioAccountType fromAccountType, final PortfolioAccountType toAccountType) {
return fromAccountType.isLoanAccount() && toAccountType.isSavingsAccount();
}
@@ -736,8 +703,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;
@@ -748,7 +715,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;
@@ -768,7 +735,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;
}
@@ -777,7 +744,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;
@@ -790,7 +757,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;
}
@@ -836,13 +803,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);
}
}
@@ -885,7 +852,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, true, true, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -61,7 +61,6 @@ 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;
@@ -98,14 +97,12 @@ public class AccountConfiguration {
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard,
SavingsInterestTdsService savingsInterestTdsService) {
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard,
savingsInterestTdsService);
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
}
@Bean
@@ -122,26 +122,11 @@ public class BranchDayRegisterApiResource {
result.put("officeId", officeId);
result.put("canOperate", canOperateDay(currentUser));
result.put("days", mapDays(rows));
result.put("today", DateUtils.getBusinessLocalDate().toString());
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 ────────────────────────────
/**
@@ -154,7 +139,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 = DateUtils.getBusinessLocalDate();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
@@ -268,7 +253,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",
DateUtils.getBusinessLocalDate());
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
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);
@@ -462,7 +447,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",
DateUtils.getBusinessLocalDate());
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
@@ -789,13 +774,11 @@ public class BranchDayRegisterApiResource {
final Long registerId = ((Number) found.get(0).get("id")).longValue();
saveDenominationDetail(registerId, "CLOSING", denominations);
snapshotVaultDenominations(officeId, registerId, businessDate);
final int depositsClosed = closeMaturedZeroBalanceRecurringDeposits(officeId, businessDate, currentUser);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
res.put("status", "CLOSED");
res.put("zeroBalanceDepositsClosed", depositsClosed);
res.put("closingCash", closingCash);
res.put("bookCash", bookCash);
res.put("cashDifference", difference);
@@ -803,38 +786,6 @@ public class BranchDayRegisterApiResource {
return gson.toJson(res);
}
/**
* "Account Closed with Zero balance in Day Closing" on a recurring deposit product: at the branch's day close, each
* of its recurring deposits that has MATURED with nothing left in it - no balance, and no loan holding a lien on it
* - is marked Closed on that business date, so it no longer waits in the matured list.
*
* Only matured deposits: a running deposit sits at zero between opening and its first installment, and must never
* be closed for that. Nothing is paid out (there is nothing to pay), so no transaction or journal entry is made.
*
* @return how many deposits were closed
*/
private int closeMaturedZeroBalanceRecurringDeposits(final Long officeId, final LocalDate businessDate, final AppUser currentUser) {
final int closed = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET status_enum = 600, closedon_date = ?, closedon_userid = ?
FROM m_savings_product sp, m_client c
WHERE sp.id = sa.product_id AND c.id = sa.client_id AND c.office_id = ?
AND sa.deposit_type_enum = 300 AND sa.status_enum = 800
AND COALESCE(sp.account_closed_zero_balance_in_day_closing, false) = true
AND COALESCE(sa.account_balance_derived, 0) = 0
AND NOT EXISTS (SELECT 1 FROM m_portfolio_account_associations paa
JOIN m_guarantor_funding_details gfd ON gfd.account_associations_id = paa.id
JOIN m_loan l ON l.id = paa.loan_account_id
WHERE paa.linked_savings_account_id = sa.id AND gfd.status_enum = 100
AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0)
""", Date.valueOf(businessDate), currentUser.getId(), officeId);
if (closed > 0) {
log.info("Day close {}: office {} closed {} matured recurring deposit(s) with zero balance", businessDate, officeId, closed);
}
return closed;
}
// ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ────
/**
@@ -850,7 +801,7 @@ public class BranchDayRegisterApiResource {
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank())
? LocalDate.parse(businessDateParam)
: DateUtils.getBusinessLocalDate();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<Long> registerIds = jdbcTemplate.queryForList(
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
@@ -727,7 +727,7 @@ public class CashierDepositRequestApiResource {
final Long savingsAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject 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 = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject 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 = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -844,7 +844,7 @@ public class CashierDepositRequestApiResource {
return;
}
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject 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 = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject 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 = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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,9 +1073,8 @@ 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. No date falls back to the
// business date for the same reason.
Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()),
// drawer on another and leave both days' cash wrong.
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
cashierId, cashierUsername, paymentTypeName
);
}
@@ -1180,7 +1179,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 DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
@@ -83,7 +83,7 @@ public class CashReportApiResource {
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
@QueryParam("status") final String status) {
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 : DateUtils.getBusinessLocalDate().toString();
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 = DateUtils.getBusinessLocalDate().toString();
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 : DateUtils.getBusinessLocalDate().toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 : DateUtils.getBusinessLocalDate().toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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
: DateUtils.getBusinessLocalDate().toString();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 : DateUtils.getBusinessLocalDate().toString();
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 : DateUtils.getBusinessLocalDate().toString();
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final int resolvedLimit = (limit != null && limit > 0) ? limit : 20;
final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0;
@@ -89,7 +89,7 @@ public class CashSettlementApiResource {
}
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate();
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
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",
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 : DateUtils.getBusinessLocalDate().toString();
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
Integer.class, cashierId, settlementDate);
@@ -146,7 +146,7 @@ public class CashTransferApiResource {
} catch (Exception ignored) {
// intentionally ignored
}
final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
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",
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).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 : DateUtils.getBusinessLocalDate();
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
final List<Long> registerIds = jdbcTemplate.query("""
@@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource {
final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root);
final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank())
? LocalDate.parse(txnDateStr)
: DateUtils.getBusinessLocalDate();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) {
return "{\"result\":\"skipped\"}";
@@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource {
final Long resolvedClientId = toLong(dep.get("client_id"));
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: DateUtils.getBusinessLocalDate();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
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()
: DateUtils.getBusinessLocalDate();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
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()
: DateUtils.getBusinessLocalDate();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// 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()
: DateUtils.getBusinessLocalDate();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
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 DateUtils.getBusinessLocalDate();
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
try {
return LocalDate.parse(raw);
@@ -176,7 +176,6 @@ public class ClerkDisbursementRequestApiResource {
// A GL leg carries no savingsAccountId and the guard ignores it.
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
// m_current_account -- checking it as a savings id would test some unrelated savings account.
rejectRecurringDepositLeg(entry, entryAccountType);
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
: KycRestrictionGuard.Operation.CREDIT;
@@ -487,8 +486,6 @@ public class ClerkDisbursementRequestApiResource {
throw apiError(400, "Account ID missing for entry: " + acNo
+ ". Clerk must re-submit with account details loaded.");
}
// Also refuses requests raised before RD legs were blocked at submit.
rejectRecurringDepositLeg(entry, entryType);
}
}
@@ -935,7 +932,6 @@ public class ClerkDisbursementRequestApiResource {
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
rejectRecurringDepositLeg(e, acTyp);
drAccountIds.add(e.get("savingsAccountId").getAsLong());
}
for (final JsonObject e : crEntries) {
@@ -948,7 +944,6 @@ public class ClerkDisbursementRequestApiResource {
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
rejectRecurringDepositLeg(e, acTyp);
final Long crId = e.get("savingsAccountId").getAsLong();
if (drAccountIds.contains(crId)) throw apiError(400,
"Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId)
@@ -1198,31 +1193,6 @@ public class ClerkDisbursementRequestApiResource {
.findFirst().orElse(null);
}
/** m_savings_account.deposit_type_enum of a recurring deposit (Fineract's DepositAccountType.RECURRING_DEPOSIT). */
private static final int DEPOSIT_TYPE_RECURRING = 300;
/**
* A recurring deposit cannot be a leg of any clerk request. Every request type here posts straight into the
* account with a direct insert, which never reaches the RD's installment schedule: the installments stay unpaid,
* Auto Debit collects them a second time and the maturity amount is computed on the wrong schedule. RD
* installments come in through the cashier's Receipt (the RD deposit command) or the RD's own Auto Debit.
* Looked up by id, not trusted from the entry's label; a CURRENT leg's id belongs to m_current_account.
*/
private void rejectRecurringDepositLeg(final JsonObject entry, final String accountType) {
if ("GL".equals(accountType) || "CURRENT".equals(accountType) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_no FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
entry.get("savingsAccountId").getAsLong(), DEPOSIT_TYPE_RECURRING);
if (!rows.isEmpty()) {
throw apiError(422, "Recurring deposit " + rows.get(0).get("account_no")
+ " cannot be used in a clerk Disbursement, Collection, Fund Transfer or Bank Transfer."
+ " Pay RD installments through a cashier Receipt or Auto Debit.");
}
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -390,101 +390,6 @@ public class ClerkShareWithdrawalRequestApiResource {
public String validate(@PathParam("id") final Long id) {
final AppUser currentUser = context.authenticatedUser();
final Map<?, ?> record = loadRecord(id);
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(record);
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
/**
* Lets the clerk see the same checklist the approver will see, before the request is even
* submitted — so a request that is going to fail a check (a missing GL mapping, an unverified
* KYC, an open AML alert) is caught while someone can still fix it, not after a manager has
* already spent time reviewing it. Takes the same fields {@link #submit(String)} does and
* applies the identical Sundry-downgrade rule, so what this previews is exactly what would be
* stored and exactly what {@link #validate(Long)} would later report on the saved request —
* never a second, independently-drifting copy of the rules.
*
* <p>
* The one check {@link #validate(Long)} runs that this cannot — "Maker and Checker are
* different users" — has no real answer yet: the clerk previewing this *is* the maker, and no
* checker has been chosen. It is reported here as a fixed reminder rather than silently
* dropped, so the clerk sees why the two lists differ by exactly that one row, and knows the
* rule is still enforced later, at {@code approve()}.
*/
@POST
@Path("/preview-validate")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String previewValidate(final String body) {
context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
if (shareAccountId == null) {
throw apiError(400, "Share account id is required");
}
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
: settlementModeRaw.trim().toUpperCase();
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
// Mirrors submit()'s own downgrade: naming the product's own Share Sundry GL as the
// destination means "stop here", not a real GL settlement — see submit() for the rationale.
if ("GL".equals(settlementMode) && settlementGlAccountId != null) {
final Long productId = jdbcTemplate.queryForList("SELECT product_id FROM m_share_account WHERE id = ?", shareAccountId)
.stream().findFirst().map(r -> ((Number) r.get("product_id")).longValue()).orElse(null);
if (productId != null) {
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
if (glSundry != null && glSundry.equals(settlementGlAccountId)) {
settlementMode = "NONE";
settlementGlAccountId = null;
}
}
}
final Map<String, Object> draftRecord = new java.util.HashMap<>();
draftRecord.put("resolution_number", resolutionNumber);
draftRecord.put("meeting_date", meetingDate);
draftRecord.put("share_account_id", shareAccountId);
draftRecord.put("requested_shares", requestedShares != null ? requestedShares : 0L);
draftRecord.put("settlement_mode", settlementMode);
draftRecord.put("settlement_gl_account_id", settlementGlAccountId);
draftRecord.put("settlement_payment_type_id", settlementPaymentTypeId);
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(draftRecord);
checks.add(checkResult("Maker and Checker are different users", true,
"Verified automatically when a different manager approves"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
/**
* The full checklist a checker sees before approving a share withdrawal — share balance and
* status, loan/tenure/guarantor exposure, settlement destination and payment type, and client
* KYC/risk/AML standing. Shared verbatim between {@link #validate(Long)} (an already-submitted
* request) and {@link #previewValidate(String)} (a draft that does not exist yet), so the two
* can never silently diverge. Reads {@code record} by the same string keys both callers
* populate: resolution_number, meeting_date, share_account_id, requested_shares,
* settlement_mode, settlement_gl_account_id, settlement_payment_type_id.
*/
private List<Map<String, Object>> buildShareWithdrawalChecks(final Map<?, ?> record) {
final List<Map<String, Object>> checks = new ArrayList<>();
final String resolutionNumber = (String) record.get("resolution_number");
@@ -546,18 +451,8 @@ public class ClerkShareWithdrawalRequestApiResource {
// not-applicable rather than failed for a GL request — a share account with no linked
// savings is a perfectly valid GL-settlement case.
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
// 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who
// names the product's own Share Sundry GL as the destination is saying "stop here", so the
// request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry
// and nothing further. Like a GL settlement it never reaches a savings account, so the
// savings-side checks below do not apply to it either — running them reports a missing fund
// source mapping that no posting in this mode is ever going to read.
final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode"));
final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement;
checks.add(noSavingsLeg
? checkResult("Linked savings account is Active", true, sundryOnlySettlement
? "Not applicable — the redemption rests in Share Sundry"
: "Not applicable — settlement is a GL book-transfer")
checks.add(glSettlementMode
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
savingsAccountId == null ? "No linked savings account configured"
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
@@ -646,9 +541,6 @@ public class ClerkShareWithdrawalRequestApiResource {
final boolean settlementGlPresent = glAccountIdObj != null;
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
} else if (sundryOnlySettlement) {
checks.add(checkResult("Settlement destination recorded", true,
"Share Sundry — the redemption stops there, so no fund source mapping is read"));
} else {
boolean glMappingOk = false;
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
@@ -687,7 +579,16 @@ public class ClerkShareWithdrawalRequestApiResource {
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
}
return checks;
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
@@ -1091,8 +992,8 @@ public class ClerkShareWithdrawalRequestApiResource {
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
final BigDecimal unitPrice = jdbcTemplate
.queryForList("""
SELECT unit_price FROM m_share_account_transactions
WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL
SELECT unit_price FROM m_share_account_transaction
WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
ORDER BY id DESC LIMIT 1
""", BigDecimal.class, shareAccountId)
.stream().findFirst().orElse(null);
@@ -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.investment.api;
import java.util.Map;
import java.util.Set;
/**
* Constants for the Treasury Investment masters (Bank / Party Master and Script Master), modelled on the bank's existing
* CBS Investment screens.
*/
public final class InvestmentApiConstants {
private InvestmentApiConstants() {}
public static final String ENTITY_PARTY = "INVESTMENTPARTY";
public static final String ENTITY_SCRIPT = "INVESTMENTSCRIPT";
public static final String RESOURCE_PARTY = "investmentparty";
public static final String RESOURCE_SCRIPT = "investmentscript";
/** Instrument ("Bond Type") an investment party / script belongs to. */
public static final Set<String> BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "FD", "SHARE");
/** Bond types that carry coupon terms (ISIN, interest rate, issue date are mandatory for these). */
public static final Set<String> DEBT_BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND");
/** SLR classification on the Script Master ("GOI" = Central Government). */
public static final Set<String> SLR_TYPES = Set.of("GOI", "STATE_GOVT", "TREASURY", "NON_SLR");
/** Script Code prefix per bond type, e.g. G00001 for the first Govt Security. */
public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND",
"M", "FD", "F", "SHARE", "S");
public static final String PARTY_CODE_PREFIX = "PTY-";
// Common parameters
public static final String BOND_TYPE = "bondType";
public static final String GL_ACCOUNT_ID = "glAccountId";
public static final String ADDRESS1 = "address1";
public static final String ADDRESS2 = "address2";
public static final String ADDRESS3 = "address3";
public static final String CITY = "city";
public static final String PINCODE = "pincode";
public static final String PHONE = "phone";
public static final String FAX = "fax";
public static final String ACTIVE = "active";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
// Party parameters
public static final String PARTY_NAME = "partyName";
public static final String BANK_TYPE = "bankType";
public static final String RBI_CODE = "rbiCode";
public static final String EXPOSURE_LIMIT = "limit";
public static final String LEI_NO = "leiNo";
public static final String CP_BANK_NAME = "cpBankName";
public static final String CP_BRANCH_NAME = "cpBranchName";
public static final String CP_ACCOUNT_NO = "cpAccountNo";
public static final String CP_IFSC = "cpIfsc";
// Script parameters
public static final String SLR_TYPE = "slrType";
public static final String SCRIPT_DESC = "scriptDesc";
public static final String ISIN = "isin";
public static final String FACE_VALUE_PER_UNIT = "faceValuePerUnit";
public static final String INT_RATE = "intRate";
public static final String ISSUE_DATE = "issueDate";
public static final String MATURITY_DATE = "maturityDate";
public static final String COMPANY_NAME = "companyName";
public static final String CORP_CODE = "corpCode";
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
BANK_TYPE, GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
LOCALE);
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT,
INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
ACTIVE, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,99 @@
/**
* 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.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/parties")
@Component
@Tag(name = "Investment Bank / Party Master", description = "Counterparties an investment is bought from or placed with.")
@RequiredArgsConstructor
public class InvestmentPartyApiResource {
private final PlatformSecurityContext context;
private final InvestmentMasterReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment parties")
public List<InvestmentPartyData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
return this.readPlatformService.retrieveAllParties();
}
@GET
@Path("{partyId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment party")
public InvestmentPartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
return this.readPlatformService.retrieveParty(partyId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create an investment party")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentParty().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@PUT
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update an investment party")
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentParty(partyId).withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{partyId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop an investment party")
public CommandProcessingResult delete(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentParty(partyId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,60 @@
/**
* 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.investment.api;
import java.util.Set;
/** Constants for the Investment Purchase Entry (a deal that creates a holding). */
public final class InvestmentPurchaseApiConstants {
private InvestmentPurchaseApiConstants() {}
public static final String ENTITY = "INVESTMENTPURCHASE";
public static final String RESOURCE = "investmentpurchase";
public static final Set<String> SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2");
public static final Set<String> CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)");
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String BOND_TYPE = "bondType";
public static final String SCRIPT_ID = "scriptId";
public static final String PARTY_ID = "partyId";
public static final String DEAL_DATE = "dealDate";
public static final String DEAL_TIME = "dealTime";
public static final String SETTLEMENT_TYPE = "settlementType";
public static final String FACE_VALUE = "faceValue";
public static final String PURCHASE_RATE = "purchaseRate";
public static final String CONSIDER_AMT = "considerAmt";
public static final String NAV = "nav";
public static final String STAMP_DUTY = "stampDuty";
public static final String BROKERAGE = "brokerage";
public static final String CATEGORY = "category";
public static final String LAST_INT_DATE = "lastIntDate";
public static final String CR_GL_ACCOUNT_ID = "crGlAccountId";
public static final String SUB_TYPE = "subType";
/** Optional legacy fields (trade no, authorities, lien, delivery mode, …) kept as a JSON object. */
public static final String OTHER_DETAILS = "otherDetails";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE, OTHER_DETAILS,
LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,106 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/purchases")
@Component
@Tag(name = "Investment Purchase Entry", description = "Investment deals (Govt Security, Bond, Mutual Fund, …) — each becomes a holding.")
@RequiredArgsConstructor
public class InvestmentPurchaseApiResource {
private final PlatformSecurityContext context;
private final InvestmentPurchaseReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment purchases (holdings)")
public List<InvestmentPurchaseData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.readPlatformService.retrieveAll();
}
@GET
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment purchase")
public InvestmentPurchaseData retrieveOne(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.readPlatformService.retrieveOne(purchaseId);
}
@GET
@Path("{purchaseId}/interest-schedule")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Interest schedule of a holding", description = "Half-yearly coupons and 31 Mar / 30 Sep closing provisions "
+ "from purchase to maturity. Calculated only — nothing is posted. asOnDate (dd-MM-yyyy) defaults to the business date.")
public InvestmentInterestScheduleData retrieveInterestSchedule(
@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null
: LocalDate.parse(asOnDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save a purchase entry")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentPurchase().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop a purchase entry")
public CommandProcessingResult delete(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentPurchase(purchaseId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,89 @@
/**
* 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.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/scripts")
@Component
@Tag(name = "Investment Script Master", description = "Securities / instruments the bank invests in (Govt Security, Bond, Mutual Fund, …).")
@RequiredArgsConstructor
public class InvestmentScriptApiResource {
private final PlatformSecurityContext context;
private final InvestmentMasterReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment scripts")
public List<InvestmentScriptData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
return this.readPlatformService.retrieveAllScripts();
}
@GET
@Path("{scriptId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment script")
public InvestmentScriptData retrieveOne(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
return this.readPlatformService.retrieveScript(scriptId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create an investment script")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentScript().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@PUT
@Path("{scriptId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update an investment script")
public CommandProcessingResult update(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentScript(scriptId).withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,75 @@
/**
* 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.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/**
* Interest schedule of one holding: every half-yearly coupon and every 31 March / 30 September closing provision from
* purchase to maturity. Calculated, not yet posted.
*/
@Getter
@Builder
public class InvestmentInterestScheduleData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final BigDecimal faceValue;
private final BigDecimal unitValue;
private final BigDecimal balanceUnits;
private final BigDecimal purchaseRate;
private final BigDecimal bookValue;
private final BigDecimal intRate;
private final String dealDate;
private final String lastIntDate;
private final String maturityDate;
/** Interest paid to the seller at purchase — already in Interest Receivable. */
private final BigDecimal purchasedAccruedInterest;
private final BigDecimal couponPerHalfYear;
private final String asOnDate;
private final String interestGlCode;
private final String interestGlName;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** COUPON (cash received) or PROVISION (closing accrual). */
private final String type;
private final String intDate;
private final String fromDate;
private final String toDate;
private final int days;
/** Coupon received, or interest earned since the last coupon up to the closing. */
private final BigDecimal grossInterest;
/** Interest Receivable balance before this line (purchased accrued interest / earlier provision). */
private final BigDecimal alreadyBooked;
/** Provision to book now, or the part of the coupon that is new income. */
private final BigDecimal amount;
/** True when the date is on or before the as-on date. */
private final boolean due;
}
}
@@ -0,0 +1,62 @@
/**
* 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.investment.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
@Getter
@Builder
public class InvestmentPartyData {
private final Long id;
private final String partyCode;
private final String bondType;
private final String partyName;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String phone;
private final String fax;
private final String bankType;
private final Long glAccountId;
private final String glCode;
private final String glName;
private final String rbiCode;
private final BigDecimal limit;
private final String leiNo;
private final String cpBankName;
private final String cpBranchName;
private final String cpAccountNo;
private final String cpIfsc;
private final boolean active;
public static InvestmentPartyData from(final InvestmentParty p) {
return InvestmentPartyData.builder().id(p.getId()).partyCode(p.getPartyCode()).bondType(p.getBondType())
.partyName(p.getPartyName()).address1(p.getAddress1()).address2(p.getAddress2()).address3(p.getAddress3())
.city(p.getCity()).pincode(p.getPincode()).phone(p.getPhone()).fax(p.getFax()).bankType(p.getBankType())
.glAccountId(p.getGlAccount().getId()).glCode(p.getGlAccount().getGlCode()).glName(p.getGlAccount().getName())
.rbiCode(p.getRbiCode()).limit(p.getExposureLimit()).leiNo(p.getLeiNo()).cpBankName(p.getCpBankName())
.cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc()).active(p.isActive()).build();
}
}
@@ -0,0 +1,96 @@
/**
* 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.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
@Getter
@Builder
public class InvestmentPurchaseData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String dealNo;
private final String certNo;
private final String bondType;
private final Long scriptId;
private final String scriptCode;
private final String scriptDesc;
private final String isin;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final String dealDate;
private final String dealTime;
private final String settlementType;
private final String settlementDate;
private final BigDecimal faceValue;
private final BigDecimal purchaseRate;
private final BigDecimal units;
private final BigDecimal balanceUnits;
private final BigDecimal brokerage;
private final BigDecimal stampDuty;
private final BigDecimal bookValue;
private final BigDecimal premiumDiscount;
private final BigDecimal intRate;
private final String issueDate;
private final String maturityDate;
private final String lastIntDate;
private final Integer accruedDays;
private final BigDecimal accruedInterest;
private final BigDecimal settlementAmount;
private final BigDecimal ytm;
private final String category;
private final String subType;
private final Long investmentGlAccountId;
private final String investmentGlCode;
private final String investmentGlName;
private final Long crGlAccountId;
private final String crGlCode;
private final String crGlName;
private final String otherDetails;
private final String status;
public static InvestmentPurchaseData from(final InvestmentPurchase p) {
return InvestmentPurchaseData.builder().id(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()).bondType(p.getBondType())
.scriptId(p.getScript().getId()).scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc())
.isin(p.getScript().getIsin()).partyId(p.getParty().getId()).partyCode(p.getParty().getPartyCode())
.partyName(p.getParty().getPartyName()).dealDate(format(p.getDealDate())).dealTime(p.getDealTime())
.settlementType(p.getSettlementType()).settlementDate(format(p.getSettlementDate())).faceValue(p.getFaceValue())
.purchaseRate(p.getPurchaseRate()).units(p.getUnits()).balanceUnits(p.getBalanceUnits()).brokerage(p.getBrokerage())
.stampDuty(p.getStampDuty()).bookValue(p.getBookValue()).premiumDiscount(p.getPremiumDiscount()).intRate(p.getIntRate())
.issueDate(format(p.getIssueDate())).maturityDate(format(p.getMaturityDate())).lastIntDate(format(p.getLastIntDate()))
.accruedDays(p.getAccruedDays()).accruedInterest(p.getAccruedInterest()).settlementAmount(p.getSettlementAmount())
.ytm(p.getYtm()).category(p.getCategory()).subType(p.getSubType())
.investmentGlAccountId(p.getInvestmentGlAccount().getId()).investmentGlCode(p.getInvestmentGlAccount().getGlCode())
.investmentGlName(p.getInvestmentGlAccount().getName()).crGlAccountId(p.getCrGlAccount().getId())
.crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName()).otherDetails(p.getOtherDetails())
.status(p.getStatus()).build();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,72 @@
/**
* 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.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
@Getter
@Builder
public class InvestmentScriptData {
/** Dates go out as DD-MM-YYYY, the format the Investment screens use throughout. */
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String scriptCode;
private final String bondType;
private final String slrType;
private final String scriptDesc;
private final Long glAccountId;
private final String glCode;
private final String glName;
private final String isin;
private final BigDecimal faceValuePerUnit;
private final BigDecimal intRate;
private final String issueDate;
private final String maturityDate;
private final String companyName;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String phone;
private final String fax;
private final String corpCode;
private final boolean active;
public static InvestmentScriptData from(final InvestmentScript s) {
return InvestmentScriptData.builder().id(s.getId()).scriptCode(s.getScriptCode()).bondType(s.getBondType())
.slrType(s.getSlrType()).scriptDesc(s.getScriptDesc()).glAccountId(s.getGlAccount().getId())
.glCode(s.getGlAccount().getGlCode()).glName(s.getGlAccount().getName()).isin(s.getIsin())
.faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate()))
.maturityDate(format(s.getMaturityDate())).companyName(s.getCompanyName()).address1(s.getAddress1())
.address2(s.getAddress2()).address3(s.getAddress3()).city(s.getCity()).pincode(s.getPincode()).phone(s.getPhone())
.fax(s.getFax()).corpCode(s.getCorpCode()).active(s.isActive()).build();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,105 @@
/**
* 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.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Bank / Party Master — the counterparty an investment is bought from or placed with (bank, RBI, issuer, AMC, broker),
* following the bank's existing CBS Bank / Party Master.
*/
@Entity
@Table(name = "m_investment_party")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "party_code", nullable = false, length = 20, unique = true)
private String partyCode;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
@Column(name = "party_name", nullable = false, length = 150)
private String partyName;
@Column(name = "address1", length = 150)
private String address1;
@Column(name = "address2", length = 150)
private String address2;
@Column(name = "address3", length = 150)
private String address3;
@Column(name = "city", length = 100)
private String city;
@Column(name = "pincode", length = 10)
private String pincode;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "fax", length = 20)
private String fax;
@Column(name = "bank_type", length = 60)
private String bankType;
@ManyToOne
@JoinColumn(name = "gl_account_id", nullable = false)
private GLAccount glAccount;
@Column(name = "rbi_code", length = 30)
private String rbiCode;
@Column(name = "exposure_limit", scale = 6, precision = 19)
private BigDecimal exposureLimit;
@Column(name = "lei_no", length = 30)
private String leiNo;
@Column(name = "cp_bank_name", length = 150)
private String cpBankName;
@Column(name = "cp_branch_name", length = 150)
private String cpBranchName;
@Column(name = "cp_account_no", length = 40)
private String cpAccountNo;
@Column(name = "cp_ifsc", length = 11)
private String cpIfsc;
/** An inactive party is kept for history but not offered for new deals. */
@Column(name = "is_active", nullable = false)
private boolean active = true;
}
@@ -16,12 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.tds.data;
package org.apache.fineract.portfolio.investment.domain;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.springframework.data.jpa.repository.JpaRepository;
/** 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 {}
public interface InvestmentPartyRepository extends JpaRepository<InvestmentParty, Long> {}
@@ -0,0 +1,148 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Investment Purchase Entry — one deal (as in the bank's existing CBS "Govt.Security/Bond Entry"). Once saved it is the
* holding that interest, sale and redemption later work against; {@link #balanceUnits} tracks what is still held.
* Every derived figure (units, book value, accrued interest, settlement amount) is computed by the backend.
*/
@Entity
@Table(name = "m_investment_purchase")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<Long> {
/** Financial-year deal number, e.g. 202627000001 for the first deal of FY 2026-27. */
@Column(name = "deal_no", nullable = false, length = 20, unique = true)
private String dealNo;
/** Certificate number — system-generated running number. */
@Column(name = "cert_no", nullable = false, length = 20, unique = true)
private String certNo;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
@ManyToOne
@JoinColumn(name = "script_id", nullable = false)
private InvestmentScript script;
@ManyToOne
@JoinColumn(name = "party_id", nullable = false)
private InvestmentParty party;
@Column(name = "deal_date", nullable = false)
private LocalDate dealDate;
@Column(name = "deal_time", length = 8)
private String dealTime;
@Column(name = "settlement_type", nullable = false, length = 5)
private String settlementType;
@Column(name = "settlement_date", nullable = false)
private LocalDate settlementDate;
@Column(name = "face_value", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValue;
/** Price per ₹100 face value (Govt Security / Bond); NAV for a mutual fund. */
@Column(name = "purchase_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal purchaseRate;
@Column(name = "units", scale = 6, precision = 19, nullable = false)
private BigDecimal units;
@Column(name = "balance_units", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceUnits;
@Column(name = "brokerage", scale = 6, precision = 19)
private BigDecimal brokerage;
@Column(name = "stamp_duty", scale = 6, precision = 19)
private BigDecimal stampDuty;
@Column(name = "book_value", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValue;
@Column(name = "premium_discount", scale = 6, precision = 19)
private BigDecimal premiumDiscount;
@Column(name = "int_rate", scale = 4, precision = 9)
private BigDecimal intRate;
@Column(name = "issue_date")
private LocalDate issueDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "last_int_date")
private LocalDate lastIntDate;
@Column(name = "accrued_days")
private Integer accruedDays;
/** Interest paid to the seller from the last coupon date up to the deal date (30/360). */
@Column(name = "accrued_interest", scale = 6, precision = 19)
private BigDecimal accruedInterest;
@Column(name = "settlement_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal settlementAmount;
@Column(name = "ytm", scale = 4, precision = 9)
private BigDecimal ytm;
@Column(name = "category", nullable = false, length = 40)
private String category;
@Column(name = "sub_type", length = 40)
private String subType;
/** Investment GL the holding is booked to (from the script). */
@ManyToOne
@JoinColumn(name = "investment_gl_account_id", nullable = false)
private GLAccount investmentGlAccount;
/** Bank GL the purchase is paid from ("CR. To A/c"). */
@ManyToOne
@JoinColumn(name = "cr_gl_account_id", nullable = false)
private GLAccount crGlAccount;
@Column(name = "other_details", length = 4000)
private String otherDetails;
@Column(name = "status", nullable = false, length = 20)
private String status;
}
@@ -0,0 +1,23 @@
/**
* 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.investment.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentPurchaseRepository extends JpaRepository<InvestmentPurchase, Long> {}
@@ -0,0 +1,107 @@
/**
* 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.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Script Master — what is being bought (a Govt Security, bond, mutual fund scheme, share, …), following the bank's
* existing CBS Script Master. A purchase picks these terms up instead of the dealer re-typing them.
*/
@Entity
@Table(name = "m_investment_script")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentScript extends AbstractAuditableWithUTCDateTimeCustom<Long> {
/** System-generated: bond-type letter + running number, e.g. G00001. */
@Column(name = "script_code", nullable = false, length = 20, unique = true)
private String scriptCode;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
/** GOI / STATE_GOVT / TREASURY / NON_SLR. */
@Column(name = "slr_type", length = 20)
private String slrType;
@Column(name = "script_desc", nullable = false, length = 150)
private String scriptDesc;
@ManyToOne
@JoinColumn(name = "gl_account_id", nullable = false)
private GLAccount glAccount;
@Column(name = "isin", length = 20)
private String isin;
@Column(name = "face_value_per_unit", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValuePerUnit;
@Column(name = "int_rate", scale = 4, precision = 9)
private BigDecimal intRate;
@Column(name = "issue_date")
private LocalDate issueDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "company_name", length = 150)
private String companyName;
@Column(name = "address1", length = 150)
private String address1;
@Column(name = "address2", length = 150)
private String address2;
@Column(name = "address3", length = 150)
private String address3;
@Column(name = "city", length = 100)
private String city;
@Column(name = "pincode", length = 10)
private String pincode;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "fax", length = 20)
private String fax;
@Column(name = "corp_code", length = 30)
private String corpCode;
@Column(name = "is_active", nullable = false)
private boolean active = true;
}
@@ -0,0 +1,23 @@
/**
* 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.investment.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentScriptRepository extends JpaRepository<InvestmentScript, Long> {}
@@ -0,0 +1,28 @@
/**
* 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.investment.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentPartyNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentPartyNotFoundException(final Long id) {
super("error.msg.investmentparty.id.invalid", "Investment party with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* 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.investment.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentPurchaseNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentPurchaseNotFoundException(final Long id) {
super("error.msg.investmentpurchase.id.invalid", "Investment purchase with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* 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.investment.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentScriptNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentScriptNotFoundException(final Long id) {
super("error.msg.investmentscript.id.invalid", "Investment script with identifier " + id + " does not exist", id);
}
}
@@ -16,28 +16,27 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler;
package org.apache.fineract.portfolio.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Tenure Flexibility: lengthens a running recurring deposit's term. */
@Service
@CommandType(entity = "INVESTMENTPARTY", action = "CREATE")
@RequiredArgsConstructor
@CommandType(entity = "RECURRINGDEPOSITACCOUNT", action = "EXTENDTENURE")
public class ExtendTenureRecurringDepositAccountCommandHandler implements NewCommandSourceHandler {
public class CreateInvestmentPartyCommandHandler implements NewCommandSourceHandler {
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.depositAccountWritePlatformService.extendRecurringDepositTenure(command.entityId(), command);
return this.writePlatformService.createParty(command);
}
}
@@ -0,0 +1,42 @@
/**
* 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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPURCHASE", action = "CREATE")
@RequiredArgsConstructor
public class CreateInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentPurchaseWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createPurchase(command);
}
}
@@ -0,0 +1,42 @@
/**
* 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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSCRIPT", action = "CREATE")
@RequiredArgsConstructor
public class CreateInvestmentScriptCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createScript(command);
}
}
@@ -0,0 +1,42 @@
/**
* 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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPARTY", action = "DELETE")
@RequiredArgsConstructor
public class DeleteInvestmentPartyCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteParty(command.entityId());
}
}
@@ -0,0 +1,42 @@
/**
* 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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPURCHASE", action = "DELETE")
@RequiredArgsConstructor
public class DeleteInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentPurchaseWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deletePurchase(command.entityId());
}
}
@@ -0,0 +1,42 @@
/**
* 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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPARTY", action = "UPDATE")
@RequiredArgsConstructor
public class UpdateInvestmentPartyCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateParty(command.entityId(), command);
}
}
@@ -0,0 +1,42 @@
/**
* 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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSCRIPT", action = "UPDATE")
@RequiredArgsConstructor
public class UpdateInvestmentScriptCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateScript(command.entityId(), command);
}
}
@@ -0,0 +1,254 @@
/**
* 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.investment.serialization;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS1;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS2;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS3;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BANK_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.COMPANY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CORP_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_ACCOUNT_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BANK_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BRANCH_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_IFSC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.DEBT_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.EXPOSURE_LIMIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_PARTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_SCRIPT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPES;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.stereotype.Component;
/** Validates Bank / Party Master and Script Master requests before anything is written. */
@Component
@RequiredArgsConstructor
public final class InvestmentMasterCommandFromApiJsonDeserializer {
private static final String IFSC_PATTERN = "^[A-Z]{4}0[A-Z0-9]{6}$";
private static final String PINCODE_PATTERN = "^[0-9]{6}$";
private final FromJsonHelper fromApiJsonHelper;
// -------------------- Bank / Party Master --------------------
public void validateForCreateParty(final String json) {
validateParty(json, true);
}
public void validateForUpdateParty(final String json) {
validateParty(json, false);
}
private void validateParty(final String json, final boolean create) {
final JsonElement element = parse(json, PARTY_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE_PARTY);
validateBondType(element, v, create);
validateRequiredString(element, v, PARTY_NAME, 150, create);
validateGlAccountId(element, v, create);
validateAddress(element, v);
validateOptionalString(element, v, BANK_TYPE, 60);
validateOptionalString(element, v, RBI_CODE, 30);
validateOptionalString(element, v, LEI_NO, 30);
validateOptionalString(element, v, CP_BANK_NAME, 150);
validateOptionalString(element, v, CP_BRANCH_NAME, 150);
validateOptionalString(element, v, CP_ACCOUNT_NO, 40);
final String ifsc = this.fromApiJsonHelper.extractStringNamed(CP_IFSC, element);
if (StringUtils.isNotBlank(ifsc)) {
v.reset().parameter(CP_IFSC).value(ifsc.trim().toUpperCase()).matchesRegularExpression(IFSC_PATTERN,
"IFSC must be 11 characters: 4 letters, 0, then 6 letters/digits");
}
if (this.fromApiJsonHelper.parameterExists(EXPOSURE_LIMIT, element)) {
final BigDecimal limit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(EXPOSURE_LIMIT, element);
v.reset().parameter(EXPOSURE_LIMIT).value(limit).ignoreIfNull().zeroOrPositiveAmount();
}
throwIfErrors(errors);
}
// -------------------- Script Master --------------------
public void validateForCreateScript(final String json) {
validateScript(json, true);
}
public void validateForUpdateScript(final String json, final String existingBondType) {
validateScript(json, false, existingBondType);
}
private void validateScript(final String json, final boolean create) {
validateScript(json, create, null);
}
private void validateScript(final String json, final boolean create, final String existingBondType) {
final JsonElement element = parse(json, SCRIPT_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE_SCRIPT);
validateBondType(element, v, create);
validateRequiredString(element, v, SCRIPT_DESC, 150, create);
validateGlAccountId(element, v, create);
final String slrType = this.fromApiJsonHelper.extractStringNamed(SLR_TYPE, element);
if (StringUtils.isNotBlank(slrType)) {
v.reset().parameter(SLR_TYPE).value(slrType).isOneOfTheseValues(SLR_TYPES.toArray());
}
final String bondType = create ? this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element) : existingBondType;
final boolean debt = bondType != null && DEBT_BOND_TYPES.contains(bondType);
final String isin = this.fromApiJsonHelper.extractStringNamed(ISIN, element);
if (create && debt) {
v.reset().parameter(ISIN).value(isin).notBlank();
}
v.reset().parameter(ISIN).value(isin).ignoreIfNull().notExceedingLengthOf(20);
if (this.fromApiJsonHelper.parameterExists(INT_RATE, element) || (create && debt)) {
final BigDecimal intRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(INT_RATE, element);
final DataValidatorBuilder rate = v.reset().parameter(INT_RATE).value(intRate);
if (create && debt) {
rate.notNull();
}
rate.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(FACE_VALUE_PER_UNIT, element)) {
final BigDecimal fv = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FACE_VALUE_PER_UNIT, element);
v.reset().parameter(FACE_VALUE_PER_UNIT).value(fv).ignoreIfNull().positiveAmount();
}
LocalDate issueDate = null;
if (this.fromApiJsonHelper.parameterExists(ISSUE_DATE, element) || (create && debt)) {
issueDate = this.fromApiJsonHelper.extractLocalDateNamed(ISSUE_DATE, element);
if (create && debt) {
v.reset().parameter(ISSUE_DATE).value(issueDate).notNull();
}
}
if (this.fromApiJsonHelper.parameterExists(MATURITY_DATE, element)) {
final LocalDate maturityDate = this.fromApiJsonHelper.extractLocalDateNamed(MATURITY_DATE, element);
v.reset().parameter(MATURITY_DATE).value(maturityDate).ignoreIfNull().validateDateAfter(issueDate);
}
validateOptionalString(element, v, COMPANY_NAME, 150);
validateOptionalString(element, v, CORP_CODE, 30);
validateAddress(element, v);
throwIfErrors(errors);
}
// -------------------- shared --------------------
private void validateBondType(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
final String bondType = this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element);
if (required) {
v.reset().parameter(BOND_TYPE).value(bondType).notBlank();
}
if (StringUtils.isNotBlank(bondType)) {
v.reset().parameter(BOND_TYPE).value(bondType).isOneOfTheseValues(BOND_TYPES.toArray());
}
}
private void validateGlAccountId(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
if (!required && !this.fromApiJsonHelper.parameterExists(GL_ACCOUNT_ID, element)) {
return;
}
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
v.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().longGreaterThanZero();
}
private void validateRequiredString(final JsonElement element, final DataValidatorBuilder v, final String param, final int maxLength,
final boolean required) {
if (!required && !this.fromApiJsonHelper.parameterExists(param, element)) {
return;
}
final String value = this.fromApiJsonHelper.extractStringNamed(param, element);
v.reset().parameter(param).value(value).notBlank().notExceedingLengthOf(maxLength);
}
private void validateOptionalString(final JsonElement element, final DataValidatorBuilder v, final String param,
final int maxLength) {
final String value = this.fromApiJsonHelper.extractStringNamed(param, element);
v.reset().parameter(param).value(value).ignoreIfNull().notExceedingLengthOf(maxLength);
}
private void validateAddress(final JsonElement element, final DataValidatorBuilder v) {
validateOptionalString(element, v, ADDRESS1, 150);
validateOptionalString(element, v, ADDRESS2, 150);
validateOptionalString(element, v, ADDRESS3, 150);
validateOptionalString(element, v, CITY, 100);
validateOptionalString(element, v, PHONE, 20);
validateOptionalString(element, v, FAX, 20);
final String pincode = this.fromApiJsonHelper.extractStringNamed(PINCODE, element);
if (StringUtils.isNotBlank(pincode)) {
v.reset().parameter(PINCODE).value(pincode.trim()).matchesRegularExpression(PINCODE_PATTERN, "Pincode must be 6 digits");
}
}
private JsonElement parse(final String json, final Set<String> supportedParams) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParams);
return this.fromApiJsonHelper.parse(json);
}
private void throwIfErrors(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
}
@@ -0,0 +1,140 @@
/**
* 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.investment.serialization;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORIES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_TIME;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.RESOURCE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.springframework.stereotype.Component;
/** Validates a Purchase Entry before anything is computed or written. */
@Component
@RequiredArgsConstructor
public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
private static final String TIME_PATTERN = "^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$";
private final FromJsonHelper fromApiJsonHelper;
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, PARAMS);
final JsonElement element = this.fromApiJsonHelper.parse(json);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
final String bondType = this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element);
v.reset().parameter(BOND_TYPE).value(bondType).notBlank();
if (StringUtils.isNotBlank(bondType)) {
v.reset().parameter(BOND_TYPE).value(bondType).isOneOfTheseValues(InvestmentApiConstants.BOND_TYPES.toArray());
}
v.reset().parameter(SCRIPT_ID).value(this.fromApiJsonHelper.extractLongNamed(SCRIPT_ID, element)).notNull().longGreaterThanZero();
v.reset().parameter(PARTY_ID).value(this.fromApiJsonHelper.extractLongNamed(PARTY_ID, element)).notNull().longGreaterThanZero();
v.reset().parameter(CR_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(CR_GL_ACCOUNT_ID, element)).notNull()
.longGreaterThanZero();
v.reset().parameter(DEAL_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(DEAL_DATE, element)).notNull();
final String settlementType = this.fromApiJsonHelper.extractStringNamed(SETTLEMENT_TYPE, element);
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType).notBlank();
if (StringUtils.isNotBlank(settlementType)) {
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType).isOneOfTheseValues(SETTLEMENT_TYPES.toArray());
}
final String category = this.fromApiJsonHelper.extractStringNamed(CATEGORY, element);
v.reset().parameter(CATEGORY).value(category).notBlank();
if (StringUtils.isNotBlank(category)) {
v.reset().parameter(CATEGORY).value(category).isOneOfTheseValues(CATEGORIES.toArray());
}
if ("MUTUAL_FUND".equals(bondType)) {
v.reset().parameter(CONSIDER_AMT).value(amount(CONSIDER_AMT, element)).notNull().positiveAmount();
v.reset().parameter(NAV).value(amount(NAV, element)).notNull().positiveAmount();
} else {
v.reset().parameter(FACE_VALUE).value(amount(FACE_VALUE, element)).notNull().positiveAmount();
v.reset().parameter(PURCHASE_RATE).value(amount(PURCHASE_RATE, element)).notNull().positiveAmount();
v.reset().parameter(LAST_INT_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(LAST_INT_DATE, element)).notNull();
}
v.reset().parameter(BROKERAGE).value(amount(BROKERAGE, element)).ignoreIfNull().zeroOrPositiveAmount();
v.reset().parameter(STAMP_DUTY).value(amount(STAMP_DUTY, element)).ignoreIfNull().zeroOrPositiveAmount();
final String dealTime = this.fromApiJsonHelper.extractStringNamed(DEAL_TIME, element);
if (StringUtils.isNotBlank(dealTime)) {
v.reset().parameter(DEAL_TIME).value(dealTime).matchesRegularExpression(TIME_PATTERN, "Deal Time must be HH:mm:ss (24 hr)");
}
v.reset().parameter(SUB_TYPE).value(this.fromApiJsonHelper.extractStringNamed(SUB_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(40);
if (this.fromApiJsonHelper.parameterExists(OTHER_DETAILS, element)) {
final JsonElement other = element.getAsJsonObject().get(OTHER_DETAILS);
if (!other.isJsonNull() && !other.isJsonObject()) {
v.reset().parameter(OTHER_DETAILS).failWithCode("must.be.an.object");
} else if (other.isJsonObject() && other.toString().length() > 4000) {
v.reset().parameter(OTHER_DETAILS).failWithCode("too.long");
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private BigDecimal amount(final String param, final JsonElement element) {
return this.fromApiJsonHelper.parameterExists(param, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(param, element)
: null;
}
}
@@ -0,0 +1,169 @@
/**
* 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.investment.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.Month;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
/**
* Interest arithmetic for half-yearly coupon securities, as the bank's existing CBS does it (Jitendra Sir):
* <ul>
* <li>days are counted 30/360 — every month is 30 days, a half-year is 180, a year 360;</li>
* <li>the coupon is fixed: face value × rate ÷ 2, paid on the maturity day/month and six months either side;</li>
* <li>at each closing (31 March, 30 September) the interest earned since the last coupon is booked as a provision (Dr
* Interest Receivable / Cr Interest Income), even though the cash arrives at the next coupon;</li>
* <li>when the coupon arrives, the provision already booked is cleared and only the rest is new income.</li>
* </ul>
*/
public final class InvestmentInterestCalculator {
private static final BigDecimal THIRTY_SIX_THOUSAND = BigDecimal.valueOf(36000);
private static final BigDecimal TWO_HUNDRED = BigDecimal.valueOf(200);
public static final String TYPE_PROVISION = "PROVISION";
public static final String TYPE_COUPON = "COUPON";
private InvestmentInterestCalculator() {}
/** One line of the interest schedule. */
public record ScheduleRow(String type, LocalDate date, LocalDate fromDate, LocalDate toDate, int days, BigDecimal grossInterest,
BigDecimal alreadyBooked, BigDecimal amount) {
}
/**
* Days between two dates on a 30/360 basis. Counting runs from the day after {@code from} up to and including
* {@code to}: 10-08 → 30-09 is 50 days, 10-08 → 08-10 is 58 days, 30-06 → 30-09 is 90 days.
*/
public static int days360(final LocalDate from, final LocalDate to) {
if (from == null || to == null || !to.isAfter(from)) {
return 0;
}
final int d1 = Math.min(from.getDayOfMonth(), 30);
final int d2 = Math.min(to.getDayOfMonth(), 30);
return (to.getYear() - from.getYear()) * 360 + (to.getMonthValue() - from.getMonthValue()) * 30 + (d2 - d1);
}
/** Interest on {@code faceValue} at {@code ratePercent} p.a. for {@code days} (30/360), rounded to the rupee. */
public static BigDecimal interestForDays(final BigDecimal faceValue, final BigDecimal ratePercent, final int days) {
return faceValue.multiply(ratePercent).multiply(BigDecimal.valueOf(days)).divide(THIRTY_SIX_THOUSAND, 0, RoundingMode.HALF_UP);
}
/** The fixed half-yearly coupon: face value × rate ÷ 2. */
public static BigDecimal halfYearCoupon(final BigDecimal faceValue, final BigDecimal ratePercent) {
return faceValue.multiply(ratePercent).divide(TWO_HUNDRED, 0, RoundingMode.HALF_UP);
}
/** k-th coupon date counting back from maturity — measured from maturity each time so month-end days survive. */
private static LocalDate couponBack(final LocalDate maturity, final int k) {
return maturity.minusMonths(6L * k);
}
/** The most recent coupon date on or before {@code on}. */
public static LocalDate lastCouponOnOrBefore(final LocalDate maturity, final LocalDate on) {
int k = 0;
LocalDate d = maturity;
while (d.isAfter(on)) {
k++;
d = couponBack(maturity, k);
}
return d;
}
/** Coupon dates strictly after {@code from}, up to and including maturity, in date order. */
public static List<LocalDate> couponDatesAfter(final LocalDate maturity, final LocalDate from) {
final List<LocalDate> dates = new ArrayList<>();
int k = 0;
LocalDate d = maturity;
while (d.isAfter(from)) {
dates.add(0, d);
k++;
d = couponBack(maturity, k);
}
return dates;
}
/** Closing dates (31 March, 30 September) from {@code from} up to {@code to}, inclusive. */
public static List<LocalDate> closingDates(final LocalDate from, final LocalDate to) {
final List<LocalDate> dates = new ArrayList<>();
for (int year = from.getYear(); year <= to.getYear(); year++) {
for (final LocalDate closing : List.of(LocalDate.of(year, Month.MARCH, 31), LocalDate.of(year, Month.SEPTEMBER, 30))) {
if (!closing.isBefore(from) && !closing.isAfter(to)) {
dates.add(closing);
}
}
}
return dates;
}
/**
* The full interest schedule of a holding, from purchase to maturity.
*
* @param faceValue
* face value currently held
* @param ratePercent
* coupon rate % p.a.
* @param maturity
* maturity date (coupons fall on its day/month and six months either side)
* @param lastIntDate
* coupon date immediately before the purchase
* @param dealDate
* purchase deal date — closings before it belong to the seller
* @param purchasedAccrued
* interest paid to the seller at purchase; it already sits in Interest Receivable
*/
public static List<ScheduleRow> schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity,
final LocalDate lastIntDate, final LocalDate dealDate, final BigDecimal purchasedAccrued) {
final List<ScheduleRow> rows = new ArrayList<>();
if (faceValue == null || ratePercent == null || ratePercent.signum() <= 0 || maturity == null || lastIntDate == null
|| dealDate == null || !maturity.isAfter(lastIntDate)) {
return rows;
}
final List<LocalDate> coupons = couponDatesAfter(maturity, lastIntDate);
final TreeSet<LocalDate> events = new TreeSet<>(coupons);
for (final LocalDate closing : closingDates(dealDate, maturity)) {
if (closing.isAfter(lastIntDate) && !events.contains(closing)) {
events.add(closing);
}
}
final BigDecimal coupon = halfYearCoupon(faceValue, ratePercent);
BigDecimal receivable = purchasedAccrued == null ? BigDecimal.ZERO : purchasedAccrued;
LocalDate previousCoupon = lastIntDate;
for (final LocalDate date : events) {
final int days = days360(previousCoupon, date);
if (coupons.contains(date)) {
rows.add(new ScheduleRow(TYPE_COUPON, date, previousCoupon.plusDays(1), date, days, coupon, receivable,
coupon.subtract(receivable)));
receivable = BigDecimal.ZERO;
previousCoupon = date;
} else {
final BigDecimal earned = interestForDays(faceValue, ratePercent, days);
rows.add(new ScheduleRow(TYPE_PROVISION, date, previousCoupon.plusDays(1), date, days, earned, receivable,
earned.subtract(receivable)));
receivable = earned;
}
}
return rows;
}
}
@@ -0,0 +1,34 @@
/**
* 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.investment.service;
import java.util.List;
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
public interface InvestmentMasterReadPlatformService {
List<InvestmentPartyData> retrieveAllParties();
InvestmentPartyData retrieveParty(Long partyId);
List<InvestmentScriptData> retrieveAllScripts();
InvestmentScriptData retrieveScript(Long scriptId);
}
@@ -0,0 +1,65 @@
/**
* 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.investment.service;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Masters are small (tens of rows), so they are listed whole, newest first, the way the screens show them. */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvestmentMasterReadPlatformServiceImpl implements InvestmentMasterReadPlatformService {
private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "id");
private final InvestmentPartyRepository partyRepository;
private final InvestmentScriptRepository scriptRepository;
@Override
public List<InvestmentPartyData> retrieveAllParties() {
return this.partyRepository.findAll(NEWEST_FIRST).stream().map(InvestmentPartyData::from).toList();
}
@Override
public InvestmentPartyData retrieveParty(final Long partyId) {
return this.partyRepository.findById(partyId).map(InvestmentPartyData::from)
.orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
}
@Override
public List<InvestmentScriptData> retrieveAllScripts() {
return this.scriptRepository.findAll(NEWEST_FIRST).stream().map(InvestmentScriptData::from).toList();
}
@Override
public InvestmentScriptData retrieveScript(final Long scriptId) {
return this.scriptRepository.findById(scriptId).map(InvestmentScriptData::from)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
}
}
@@ -0,0 +1,35 @@
/**
* 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.investment.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface InvestmentMasterWritePlatformService {
CommandProcessingResult createParty(JsonCommand command);
CommandProcessingResult updateParty(Long partyId, JsonCommand command);
CommandProcessingResult deleteParty(Long partyId);
CommandProcessingResult createScript(JsonCommand command);
CommandProcessingResult updateScript(Long scriptId, JsonCommand command);
}
@@ -0,0 +1,298 @@
/**
* 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.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS1;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS2;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS3;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BANK_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.COMPANY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CORP_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_ACCOUNT_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BANK_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BRANCH_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_IFSC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.EXPOSURE_LIMIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_CODE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
import java.math.BigDecimal;
import java.util.function.Consumer;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.apache.fineract.portfolio.investment.serialization.InvestmentMasterCommandFromApiJsonDeserializer;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMasterWritePlatformService {
private final InvestmentMasterCommandFromApiJsonDeserializer validator;
private final InvestmentPartyRepository partyRepository;
private final InvestmentScriptRepository scriptRepository;
private final GLAccountRepositoryWrapper glAccountRepository;
private final JdbcTemplate jdbcTemplate;
// -------------------- Bank / Party Master --------------------
@Override
@Transactional
public CommandProcessingResult createParty(final JsonCommand command) {
this.validator.validateForCreateParty(command.json());
final InvestmentParty party = new InvestmentParty();
party.setPartyCode(nextPartyCode());
applyParty(party, command);
if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
final InvestmentParty saved = saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getPartyCode()).build();
}
@Override
@Transactional
public CommandProcessingResult updateParty(final Long partyId, final JsonCommand command) {
this.validator.validateForUpdateParty(command.json());
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
applyParty(party, command);
if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId)
.withResourceIdAsString(party.getPartyCode()).build();
}
@Override
@Transactional
public CommandProcessingResult deleteParty(final Long partyId) {
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
this.partyRepository.delete(party);
this.partyRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(partyId).withResourceIdAsString(party.getPartyCode()).build();
}
/** Copies every party field present in the request onto the entity (absent fields are left unchanged). */
private void applyParty(final InvestmentParty p, final JsonCommand command) {
setIfPresent(command, BOND_TYPE, p::setBondType);
setIfPresent(command, PARTY_NAME, p::setPartyName);
setIfPresent(command, ADDRESS1, p::setAddress1);
setIfPresent(command, ADDRESS2, p::setAddress2);
setIfPresent(command, ADDRESS3, p::setAddress3);
setIfPresent(command, CITY, p::setCity);
setIfPresent(command, PINCODE, p::setPincode);
setIfPresent(command, PHONE, p::setPhone);
setIfPresent(command, FAX, p::setFax);
setIfPresent(command, BANK_TYPE, p::setBankType);
setIfPresent(command, RBI_CODE, p::setRbiCode);
setIfPresent(command, LEI_NO, p::setLeiNo);
setIfPresent(command, CP_BANK_NAME, p::setCpBankName);
setIfPresent(command, CP_BRANCH_NAME, p::setCpBranchName);
setIfPresent(command, CP_ACCOUNT_NO, p::setCpAccountNo);
if (command.parameterExists(CP_IFSC)) {
final String ifsc = command.stringValueOfParameterNamedAllowingNull(CP_IFSC);
p.setCpIfsc(StringUtils.isBlank(ifsc) ? null : ifsc.trim().toUpperCase());
}
if (command.parameterExists(EXPOSURE_LIMIT)) {
p.setExposureLimit(command.bigDecimalValueOfParameterNamed(EXPOSURE_LIMIT));
}
if (command.parameterExists(GL_ACCOUNT_ID)) {
p.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID)));
}
}
private InvestmentParty saveParty(final InvestmentParty party) {
try {
return this.partyRepository.saveAndFlush(party);
} catch (final DataIntegrityViolationException e) {
throw new PlatformDataIntegrityException("error.msg.investmentparty.duplicate", "A party with this code already exists",
"partyCode", party.getPartyCode(), e);
}
}
/** Next code in the PTY-001, PTY-002 … series. */
private String nextPartyCode() {
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(party_code FROM 5) AS INTEGER)) FROM m_investment_party WHERE party_code ~ '^PTY-[0-9]+$'",
Integer.class);
return PARTY_CODE_PREFIX + String.format("%03d", (max == null ? 0 : max) + 1);
}
// -------------------- Script Master --------------------
@Override
@Transactional
public CommandProcessingResult createScript(final JsonCommand command) {
this.validator.validateForCreateScript(command.json());
final InvestmentScript script = new InvestmentScript();
script.setBondType(command.stringValueOfParameterNamed(BOND_TYPE));
script.setScriptCode(nextScriptCode(script.getBondType()));
script.setFaceValuePerUnit(defaultFaceValue(script.getBondType()));
applyScript(script, command);
if (command.parameterExists(ACTIVE)) {
script.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
final InvestmentScript saved = saveScript(script);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getScriptCode()).build();
}
@Override
@Transactional
public CommandProcessingResult updateScript(final Long scriptId, final JsonCommand command) {
final InvestmentScript script = this.scriptRepository.findById(scriptId)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
this.validator.validateForUpdateScript(command.json(), script.getBondType());
if (command.parameterExists(BOND_TYPE) && !script.getBondType().equals(command.stringValueOfParameterNamed(BOND_TYPE))) {
// The script code carries the bond-type letter (G00001), so the type cannot change once created.
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.bondtype.immutable",
"Bond Type of an existing script cannot be changed", script.getScriptCode());
}
applyScript(script, command);
if (command.parameterExists(ACTIVE)) {
script.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
saveScript(script);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(scriptId)
.withResourceIdAsString(script.getScriptCode()).build();
}
private void applyScript(final InvestmentScript s, final JsonCommand command) {
setIfPresent(command, SLR_TYPE, s::setSlrType);
setIfPresent(command, SCRIPT_DESC, s::setScriptDesc);
setIfPresent(command, ISIN, s::setIsin);
setIfPresent(command, COMPANY_NAME, s::setCompanyName);
setIfPresent(command, ADDRESS1, s::setAddress1);
setIfPresent(command, ADDRESS2, s::setAddress2);
setIfPresent(command, ADDRESS3, s::setAddress3);
setIfPresent(command, CITY, s::setCity);
setIfPresent(command, PINCODE, s::setPincode);
setIfPresent(command, PHONE, s::setPhone);
setIfPresent(command, FAX, s::setFax);
setIfPresent(command, CORP_CODE, s::setCorpCode);
if (command.parameterExists(FACE_VALUE_PER_UNIT) && command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT) != null) {
s.setFaceValuePerUnit(command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT));
}
if (command.parameterExists(INT_RATE)) {
s.setIntRate(command.bigDecimalValueOfParameterNamed(INT_RATE));
}
if (command.parameterExists(ISSUE_DATE)) {
s.setIssueDate(command.localDateValueOfParameterNamed(ISSUE_DATE));
}
if (command.parameterExists(MATURITY_DATE)) {
s.setMaturityDate(command.localDateValueOfParameterNamed(MATURITY_DATE));
}
if (s.getIssueDate() != null && s.getMaturityDate() != null && s.getMaturityDate().isBefore(s.getIssueDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.maturity.before.issue",
"Maturity date cannot be before the issue date");
}
if (command.parameterExists(GL_ACCOUNT_ID)) {
s.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID)));
}
}
private InvestmentScript saveScript(final InvestmentScript script) {
try {
return this.scriptRepository.saveAndFlush(script);
} catch (final DataIntegrityViolationException e) {
throw new PlatformDataIntegrityException("error.msg.investmentscript.duplicate", "A script with this code already exists",
"scriptCode", script.getScriptCode(), e);
}
}
/** Next code for the bond type — its letter plus a five-digit running number, e.g. G00001. */
private String nextScriptCode(final String bondType) {
final String prefix = SCRIPT_CODE_PREFIX.get(bondType);
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(script_code FROM 2) AS INTEGER)) FROM m_investment_script WHERE script_code ~ ?",
Integer.class, "^" + prefix + "[0-9]{5}$");
return prefix + String.format("%05d", (max == null ? 0 : max) + 1);
}
/** Face value of one unit: ₹100 for securities and bonds, as the existing CBS defaults it. */
private BigDecimal defaultFaceValue(final String bondType) {
return switch (bondType) {
case "MUTUAL_FUND" -> BigDecimal.valueOf(1000);
case "SHARE", "FD" -> BigDecimal.ONE;
default -> BigDecimal.valueOf(100);
};
}
// -------------------- shared --------------------
/** An investment is an asset, so the mapped GL must be an enabled ASSET detail account. */
private GLAccount postableAssetGl(final Long glAccountId) {
final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId);
if (gl.isHeaderAccount()) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.header",
"GL " + gl.getGlCode() + " is a header account; choose a detail account", glAccountId);
}
if (gl.isDisabled()) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.disabled", "GL " + gl.getGlCode() + " is disabled",
glAccountId);
}
if (!GLAccountType.ASSET.getValue().equals(gl.getType())) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.not.asset",
"GL " + gl.getGlCode() + " is not an Asset account", glAccountId);
}
return gl;
}
private static void setIfPresent(final JsonCommand command, final String param, final Consumer<String> setter) {
if (command.parameterExists(param)) {
final String value = command.stringValueOfParameterNamedAllowingNull(param);
setter.accept(StringUtils.isBlank(value) ? null : value.trim());
}
}
}
@@ -0,0 +1,91 @@
/**
* 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.investment.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvestmentPurchaseReadPlatformService {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
/** Income head for coupon and provision interest (GL 400401 under 400400 INCOME FROM INVESTMENTS). */
private static final String INTEREST_GL_CODE = "400401";
private static final String INTEREST_GL_NAME = "INTEREST ON GOVT SECURITIES";
private final InvestmentPurchaseRepository purchaseRepository;
/** All deals, newest first — the Holdings register. */
public List<InvestmentPurchaseData> retrieveAll() {
return this.purchaseRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentPurchaseData::from).toList();
}
public InvestmentPurchaseData retrieveOne(final Long purchaseId) {
return InvestmentPurchaseData.from(find(purchaseId));
}
/**
* Coupon and closing-provision lines of a holding from purchase to maturity, worked out on the face value still
* held. Lines on or before {@code asOnDate} (default: business date) are marked due.
*/
public InvestmentInterestScheduleData retrieveInterestSchedule(final Long purchaseId, final LocalDate asOnDate) {
final InvestmentPurchase p = find(purchaseId);
final LocalDate asOn = asOnDate == null ? DateUtils.getBusinessLocalDate() : asOnDate;
final BigDecimal unitValue = p.getScript().getFaceValuePerUnit();
final BigDecimal faceHeld = p.getBalanceUnits().multiply(unitValue);
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
final List<InvestmentInterestScheduleData.Row> rows = InvestmentInterestCalculator
.schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getDealDate(), p.getAccruedInterest()).stream()
.map(r -> InvestmentInterestScheduleData.Row.builder().type(r.type()).intDate(format(r.date()))
.fromDate(format(r.fromDate())).toDate(format(r.toDate())).days(r.days()).grossInterest(r.grossInterest())
.alreadyBooked(r.alreadyBooked()).amount(r.amount()).due(!r.date().isAfter(asOn)).build())
.toList();
return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld)
.unitValue(unitValue).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate()).bookValue(p.getBookValue())
.intRate(p.getIntRate()).dealDate(format(p.getDealDate())).lastIntDate(format(p.getLastIntDate()))
.maturityDate(format(p.getMaturityDate())).purchasedAccruedInterest(p.getAccruedInterest())
.couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO)
.asOnDate(format(asOn)).interestGlCode(INTEREST_GL_CODE).interestGlName(INTEREST_GL_NAME).rows(rows).build();
}
private InvestmentPurchase find(final Long purchaseId) {
return this.purchaseRepository.findById(purchaseId).orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,29 @@
/**
* 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.investment.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface InvestmentPurchaseWritePlatformService {
CommandProcessingResult createPurchase(JsonCommand command);
CommandProcessingResult deletePurchase(Long purchaseId);
}
@@ -0,0 +1,262 @@
/**
* 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.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_TIME;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.apache.fineract.portfolio.investment.serialization.InvestmentPurchaseCommandFromApiJsonDeserializer;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Saves a Purchase Entry as a holding. Every derived figure is computed here from the script master and the deal
* inputs, so the stored deal does not depend on what the screen calculated. No GL entry is posted yet.
*/
@Service
@RequiredArgsConstructor
public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPurchaseWritePlatformService {
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private final InvestmentPurchaseCommandFromApiJsonDeserializer validator;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentScriptRepository scriptRepository;
private final InvestmentPartyRepository partyRepository;
private final GLAccountRepositoryWrapper glAccountRepository;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
public CommandProcessingResult createPurchase(final JsonCommand command) {
this.validator.validateForCreate(command.json());
final String bondType = command.stringValueOfParameterNamed(BOND_TYPE);
final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID);
final InvestmentScript script = this.scriptRepository.findById(scriptId)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final Long partyId = command.longValueOfParameterNamed(PARTY_ID);
final InvestmentParty party = this.partyRepository.findById(partyId)
.orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
if (!script.isActive()) {
throw rule("script.inactive", "Script " + script.getScriptCode() + " is inactive");
}
if (!script.getBondType().equals(bondType)) {
throw rule("script.bondtype.mismatch",
"Script " + script.getScriptCode() + " is a " + script.getBondType() + ", not " + bondType);
}
if (!party.isActive()) {
throw rule("party.inactive", "Party " + party.getPartyCode() + " is inactive");
}
final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE);
if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) {
throw rule("deal.after.maturity", "Deal date is after the security's maturity date " + script.getMaturityDate());
}
final String settlementType = command.stringValueOfParameterNamed(SETTLEMENT_TYPE);
final InvestmentPurchase p = new InvestmentPurchase();
p.setDealNo(nextDealNo(dealDate));
p.setCertNo(nextCertNo());
p.setBondType(bondType);
p.setScript(script);
p.setParty(party);
p.setDealDate(dealDate);
p.setDealTime(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(DEAL_TIME)));
p.setSettlementType(settlementType);
p.setSettlementDate(dealDate.plusDays(Long.parseLong(settlementType.substring(2))));
p.setCategory(command.stringValueOfParameterNamed(CATEGORY));
p.setSubType(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)));
p.setIntRate(script.getIntRate());
p.setIssueDate(script.getIssueDate());
p.setMaturityDate(script.getMaturityDate());
p.setInvestmentGlAccount(script.getGlAccount());
p.setCrGlAccount(postableAssetGl(command.longValueOfParameterNamed(CR_GL_ACCOUNT_ID)));
p.setOtherDetails(otherDetails(command));
p.setStatus(STATUS_ACTIVE);
final BigDecimal brokerage = zeroIfNull(command.bigDecimalValueOfParameterNamed(BROKERAGE));
p.setBrokerage(brokerage);
if ("MUTUAL_FUND".equals(bondType)) {
computeMutualFund(p, command, brokerage);
} else {
computeSecurity(p, command, script, brokerage);
}
final InvestmentPurchase saved = this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getDealNo()).build();
}
@Override
@Transactional
public CommandProcessingResult deletePurchase(final Long purchaseId) {
final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
if (p.getBalanceUnits().compareTo(p.getUnits()) != 0) {
throw rule("purchase.partly.sold", "Deal " + p.getDealNo() + " has been partly sold and cannot be dropped");
}
this.purchaseRepository.delete(p);
this.purchaseRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(purchaseId).withResourceIdAsString(p.getDealNo()).build();
}
/** Govt Security / Bond: face value at a price per ₹100, plus interest owed to the seller since the last coupon. */
private void computeSecurity(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script,
final BigDecimal brokerage) {
final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
final LocalDate lastIntDate = command.localDateValueOfParameterNamed(LAST_INT_DATE);
if (lastIntDate.isAfter(p.getDealDate())) {
throw rule("lastint.after.deal", "Last interest date cannot be after the deal date");
}
final BigDecimal units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP);
final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP);
final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getDealDate());
final BigDecimal intRate = zeroIfNull(script.getIntRate());
// Interest is counted up to the deal date (the event), not the settlement date — Jitendra Sir.
final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(faceValue, intRate, days);
p.setFaceValue(faceValue);
p.setPurchaseRate(rate);
p.setUnits(units);
p.setBalanceUnits(units);
p.setBookValue(cost.add(brokerage));
p.setPremiumDiscount(faceValue.multiply(rate.subtract(HUNDRED)).divide(HUNDRED, 2, RoundingMode.HALF_UP));
p.setLastIntDate(lastIntDate);
p.setAccruedDays(days);
p.setAccruedInterest(accrued);
p.setSettlementAmount(p.getBookValue().add(accrued));
p.setYtm(approxYtm(intRate, rate, script.getIssueDate(), script.getMaturityDate()));
}
/** Mutual fund: the amount invested buys units at the NAV; stamp duty and brokerage add to the cost. */
private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command, final BigDecimal brokerage) {
final BigDecimal amount = command.bigDecimalValueOfParameterNamed(CONSIDER_AMT);
final BigDecimal nav = command.bigDecimalValueOfParameterNamed(NAV);
final BigDecimal stampDuty = zeroIfNull(command.bigDecimalValueOfParameterNamed(STAMP_DUTY));
final BigDecimal units = amount.divide(nav, 3, RoundingMode.HALF_UP);
p.setFaceValue(amount);
p.setPurchaseRate(nav);
p.setUnits(units);
p.setBalanceUnits(units);
p.setStampDuty(stampDuty);
p.setBookValue(amount.add(stampDuty).add(brokerage));
p.setAccruedInterest(BigDecimal.ZERO);
p.setAccruedDays(0);
p.setSettlementAmount(p.getBookValue());
}
/** Approximate yield: coupon adjusted for the discount/premium spread over the security's life, over the price. */
static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate issue, final LocalDate maturity) {
if (coupon.signum() == 0 || issue == null || maturity == null || !maturity.isAfter(issue)) {
return coupon;
}
final double years = ChronoUnit.DAYS.between(issue, maturity) / 365.25;
final double ytm = (coupon.doubleValue() + (100 - price.doubleValue()) / years) / price.doubleValue() * 100;
return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP);
}
/** Financial-year deal number: FY start year + FY end (2 digits) + 6-digit running number, e.g. 202627000001. */
private String nextDealNo(final LocalDate dealDate) {
final String prefix = dealNoPrefix(dealDate);
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class,
prefix + "%");
return prefix + String.format("%06d", (max == null ? 0 : max) + 1);
}
/** Financial year (April–March) of the deal date: 2026-04-01 … 2027-03-31 → "202627". */
static String dealNoPrefix(final LocalDate dealDate) {
final int fyStart = dealDate.getMonthValue() >= 4 ? dealDate.getYear() : dealDate.getYear() - 1;
return fyStart + String.format("%02d", (fyStart + 1) % 100);
}
private String nextCertNo() {
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class);
return String.valueOf((max == null ? 0 : max) + 1);
}
private static String otherDetails(final JsonCommand command) {
if (!command.parameterExists(OTHER_DETAILS)) {
return null;
}
final JsonElement other = command.parsedJson().getAsJsonObject().get(OTHER_DETAILS);
return other == null || other.isJsonNull() ? null : other.toString();
}
/** The paying bank account must be an enabled ASSET detail GL. */
private GLAccount postableAssetGl(final Long glAccountId) {
final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId);
if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType())) {
throw rule("cr.glaccount.invalid", "CR. To A/c " + gl.getGlCode() + " must be an enabled Asset detail account");
}
return gl;
}
private static BigDecimal zeroIfNull(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
return new GeneralPlatformDomainRuleException("error.msg.investmentpurchase." + code, message);
}
}
@@ -55,7 +55,6 @@ import org.apache.fineract.portfolio.loanaccount.guarantor.exception.InvalidGuar
import org.apache.fineract.portfolio.loanaccount.guarantor.serialization.GuarantorCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -80,7 +79,6 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
private final SavingsAccountAssembler savingsAccountAssembler;
private final AccountAssociationsRepository accountAssociationsRepository;
private final GuarantorDomainService guarantorDomainService;
private final RecurringDepositLienService recurringDepositLienService;
@Autowired
public GuarantorWritePlatformServiceJpaRepositoryIImpl(final LoanRepositoryWrapper loanRepositoryWrapper,
@@ -88,9 +86,7 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
final StaffRepositoryWrapper staffRepositoryWrapper, final GroupRepositoryWrapper groupRepositoryWrapper,
final GuarantorCommandFromApiJsonDeserializer fromApiJsonDeserializer,
final CodeValueRepositoryWrapper codeValueRepositoryWrapper, final SavingsAccountAssembler savingsAccountAssembler,
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService,
final RecurringDepositLienService recurringDepositLienService) {
this.recurringDepositLienService = recurringDepositLienService;
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService) {
this.loanRepositoryWrapper = loanRepositoryWrapper;
this.clientRepositoryWrapper = clientRepositoryWrapper;
this.groupRepositoryWrapper = groupRepositoryWrapper;
@@ -124,9 +120,6 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
final SavingsAccount savingsAccount = this.savingsAccountAssembler.assembleFrom(guarantorCommand.getSavingsId(),
backdatedTxnsAllowedTill);
validateGuarantorSavingsAccountActivationDateWithLoanSubmittedOnDate(loan, savingsAccount);
// A recurring deposit pledged here is a lien on it: its product must allow that, and it can
// never secure more than the balance it has not already pledged.
this.recurringDepositLienService.validatePledge(savingsAccount, guarantorCommand.getAmount(), loan.getId());
accountAssociations = AccountAssociations.associateSavingsAccount(loan, savingsAccount,
AccountAssociationType.GUARANTOR_ACCOUNT_ASSOCIATION.getValue(), backdatedTxnsAllowedTill);
@@ -71,11 +71,9 @@ public class RbiReserveConfigApiResource {
static {
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
// BALANCE_WITH_OTHER_BANKS is deliberately not offered. It belongs to the older working
// report, and the statutory Form 1 this bank files has no line for it: balances with other
// banks are reported there under III(a), III(b)(i) or VI, depending on which bank holds
// them. Left on the screen it invited a mapping that would raise item X without appearing
// anywhere on the form to say where the figure came from.
ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS",
"Current account balances the bank holds with other banks. Counts toward CRR maintained. "
+ "Pin every such account - a bank typically banks with several.");
ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES",
"Deposit liabilities forming the DTL base that CRR and SLR are charged on. "
+ "Pin the deposit accounts and any interest accrued on them.");
@@ -212,14 +210,12 @@ 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 1 Part C (SLR) — maintained");
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
ROLE_USAGE.put(role, "Form 1 — base for both ratios");
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
} else {
ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained");
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
}
}
}
@@ -1084,15 +1084,6 @@ 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
@@ -1139,8 +1130,6 @@ 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);
@@ -1185,51 +1174,15 @@ public class RbiReserveReturnApiResource {
}
// The third column is the base the current fortnight's ratios are charged on - the last
// matching close one month earlier - not the reporting date.
// Friday of the second preceding fortnight - 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.
// 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 LocalDate currentBase = formOneBaseDate(currentClose);
final List<LocalDate> columnDates = List.of(previousClose, currentClose, currentBase);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("formCode", "FORM_1_STATUTORY");
@@ -1237,9 +1190,6 @@ 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);
@@ -1247,14 +1197,10 @@ public class RbiReserveReturnApiResource {
response.put("slrPercentage", slr == null ? null : slr.percentage);
final List<Map<String, Object>> parts = new ArrayList<>();
parts.add(formOnePartA(columnDates, baseDates, officeId, crr, slr));
parts.add(formOnePartA(columnDates, officeId, crr, slr));
response.put("partA", parts.get(0));
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));
response.put("partB", formOnePartB(columnDates, officeId, crr));
response.put("partC", formOnePartC(columnDates, officeId, slr));
final List<String> unmet = new ArrayList<>();
if (crr == null) {
@@ -1273,8 +1219,9 @@ 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
+ ", 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.");
+ " — 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.");
}
response.put("unmetRequirements", unmet);
@@ -1282,26 +1229,20 @@ 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 List<LocalDate> bases, final Long officeId, final Rate crr,
private Map<String, Object> formOnePartA(final List<LocalDate> dates, final Long officeId, final Rate crr,
final Rate slr) {
final List<Map<String, Object>> lines = new ArrayList<>();
// The labels below are the printed form's own wording, kept word for word. A shortened line
// reads more easily on screen but this form is filed and signed, and a reader checking it
// against the paper return has to find each line by its text - a paraphrase makes them stop
// and work out whether it is the same line.
lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM :"));
lines.add(formOneHeading("(a) Demand Liabilities:"));
final List<BigDecimal> iai = formOneLine(lines,
"(i) Total of credit balances in current accounts maintained with the co-operative bank by State Bank of India,"
+ " subsidiary banks and corresponding new banks.",
lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM"));
lines.add(formOneHeading("(a) Demand Liabilities"));
final List<BigDecimal> iai = formOneLine(lines, "(i) Current account balances of SBI, its subsidiaries and the corresponding new banks",
"FORM1_I_A_I_SBI_CURRENT", dates, officeId, 2);
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Total of other demand liabilites to the banking system",
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system",
"FORM1_I_A_II_OTHER_DEMAND_BS", dates, officeId, 2);
final List<BigDecimal> ib = formOneLine(lines, "(b) Time Liabilities to the banking system", "FORM1_I_B_TIME_BS",
final List<BigDecimal> ib = formOneLine(lines, "(b) Time liabilities to the banking system", "FORM1_I_B_TIME_BS",
dates, officeId, 1);
final List<BigDecimal> totalI = sumOf(iai, iaii, ib);
lines.add(formOneTotal("TOTAL OF I : >>>>>>>>>>>", totalI));
lines.add(formOneTotal("TOTAL OF I", totalI));
lines.add(formOneHeading("II. LIABILITIES IN INDIA TO OTHERS"));
// Demand and time come from the one DTL head, split by the class each mapped account
@@ -1313,25 +1254,23 @@ public class RbiReserveReturnApiResource {
iiA.add(dtl.demandAmount);
iiB.add(dtl.timeAmount);
}
lines.add(formOneRow("(A) DEMAND LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiA, 1));
lines.add(formOneRow("(B) TIME LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiB, 1));
lines.add(formOneRow("(A) Demand liabilities", "DEMAND_TIME_LIABILITIES", iiA, 1));
lines.add(formOneRow("(B) Time liabilities", "DEMAND_TIME_LIABILITIES", iiB, 1));
final List<BigDecimal> totalII = sumOf(iiA, iiB);
lines.add(formOneTotal("TOTAL OF II : >>>>>>>>>>>", totalII));
lines.add(formOneTotal("TOTAL OF II", totalII));
lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM"));
final List<BigDecimal> iiiA = formOneLine(lines,
"(A) TOTAL OF CREDIT BALANCES IN CURRENT ACCOUNT maintained with S.B.I, Subsidiary Banks & corresponding new banks",
final List<BigDecimal> iiiA = formOneLine(lines, "(A) Current account balances with SBI, its subsidiaries and the corresponding new banks",
"FORM1_III_A_SBI_CURRENT", dates, officeId, 1);
lines.add(formOneHeading("(B) TOTAL OF OTHER ASSETS WITH THE BANKING SYSTEM, VIZ."));
final List<BigDecimal> iiiBi = formOneLine(lines,
"(i) Balances in all Accounts other than those included in item III (a) :",
lines.add(formOneHeading("(B) Other assets with the banking system"));
final List<BigDecimal> iiiBi = formOneLine(lines, "(i) Balances in all accounts other than III(A)",
"FORM1_III_B_I_OTHER_BALANCES", dates, officeId, 2);
final List<BigDecimal> iiiBii = formOneLine(lines, "(ii) Money at Call and Short notice :",
final List<BigDecimal> iiiBii = formOneLine(lines, "(ii) Money at call and short notice",
"FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2);
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances & any other Assets :",
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances and any other assets",
"FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2);
final List<BigDecimal> totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv);
lines.add(formOneTotal("TOTAL OF III : >>>>>>>>>>", totalIII));
lines.add(formOneTotal("TOTAL OF III", totalIII));
// IV: the banking system nets out, and what is left is added to the liabilities to others.
// Floored at zero per column - the form's own wording, "(I - III) + II if minus figure",
@@ -1340,54 +1279,29 @@ 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)));
}
// 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 & TIME LIABILITIES FOR THE PURPOSE OF SEC.18 & 24 OF THE ACT = (I-III)+II IF MINUS"
+ " FIGURE [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));
}
// The printed form words this "AS ON THE LAST FRIDAY OF THE SECOND PRECEDING FORTNIGHT",
// which is the Act's rule. This bank does not file on that rule - its base is the matching
// fortnight close one month earlier, which formOneBaseDate computes - so the line says BASE
// DATE and leaves the dates themselves to the column headings. A heading that named a
// Friday the return never used would be a statement nobody could check.
final Map<String, Object> ivBaseRow = formOneTotal(
"IV. b) DEMAND AND TIME LIABILITY OF BASE DATE FOR THE PURPOSE OF SEC.18 & 24",
ivBase);
ivBaseRow.put("baseDates", ivBaseDates);
lines.add(ivBaseRow);
lines.add(formOneTotal("IV. TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24", iv));
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND :", "CASH_IN_HAND", dates, officeId, 0);
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0);
lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH :"));
final List<BigDecimal> viA = formOneLine(lines, "(a) Reserve Bank of India :", "FORM1_VI_A_RBI_CURRENT", dates,
lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH"));
final List<BigDecimal> viA = formOneLine(lines, "(a) Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", dates,
officeId, 1);
final List<BigDecimal> viB = formOneLine(lines, "(b) State Co.Op.Bank of the State Concerned :",
final List<BigDecimal> viB = formOneLine(lines, "(b) State Co-operative Bank of the state concerned",
"FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1);
final List<BigDecimal> viC = formOneLine(lines, "(c) Central Co.Op.Bank of the Dist.Concerned :",
final List<BigDecimal> viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned",
"FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1);
final List<BigDecimal> totalVI = sumOf(viA, viB, viC);
lines.add(formOneTotal("TOTAL OF VI : >>>>>>>>>>", totalVI));
lines.add(formOneTotal("TOTAL OF VI", totalVI));
lines.add(formOneHeading("VII. Balance Of all Other types With :"));
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co.Op.Bank of the State Concerned :",
lines.add(formOneHeading("VII. BALANCES OF ALL OTHER TYPES WITH"));
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned",
"FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1);
final List<BigDecimal> viiB = formOneLine(lines, "(B) CENTRAL CO.OP.BANK OF THE DIST.CONCERNED",
final List<BigDecimal> viiB = formOneLine(lines, "(b) District Central Co-operative Bank",
"FORM1_VII_B_DCCB_OTHER", dates, officeId, 1);
final List<BigDecimal> viiC = formOneLine(lines, "(C) INVESTMENT OF GOVT. SECURITY",
final List<BigDecimal> viiC = formOneLine(lines, "(c) Investment in Government securities",
"FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1);
final List<BigDecimal> totalVII = sumOf(viiA, viiB, viiC);
lines.add(formOneTotal("TOTAL OF VII : >>>>>>>>>>", totalVII));
lines.add(formOneTotal("TOTAL OF VII", totalVII));
// VIII: only the excess counts. Where the banking system's claim on this bank exceeds the
// bank's own balance there is nothing to carry into the cash reserve, so it floors at zero.
@@ -1395,27 +1309,39 @@ public class RbiReserveReturnApiResource {
for (int i = 0; i < dates.size(); i++) {
viii.add(iiiA.get(i).subtract(iai.get(i)).max(BigDecimal.ZERO));
}
lines.add(formOneTotal("VIII. Net balance in current accounts i.e.excess of III (a) over I (a) (i) :", viii));
lines.add(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", ivBaseDates);
part.put("baseDateLiabilities", ivBase);
part.put("baseDates", baseDates);
part.put("baseDateLiabilities", baseDateDtl);
return part;
}
/** Part B: compliance with section 18 — the cash reserve. */
private Map<String, Object> formOnePartB(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate crr) {
private Map<String, Object> formOnePartB(final List<LocalDate> dates, 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 (int i = 0; i < dates.size(); i++) {
final LocalDate date = dates.get(i);
final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId);
for (final LocalDate date : dates) {
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), 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.
@@ -1428,16 +1354,12 @@ public class RbiReserveReturnApiResource {
maintained.add(cash.add(currentAccounts).add(netCurrent));
}
// 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 == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE",
required));
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED := V+VI+VIII", maintained));
lines.add(formOneTotal("IX. " + (crr == null ? "CRR" : crr.percentage + "%")
+ " of IV as on the last Friday of the second preceding fortnight", required));
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained));
final Map<String, Object> part = new LinkedHashMap<>();
part.put("title", "PART B : COMPLIANCE WITH SECTION 18. (Not applicable to scheduled state co-op. Bank)");
part.put("title", "PART B — Compliance with Section 18");
part.put("lines", lines);
part.put("required", required);
part.put("maintained", maintained);
@@ -1446,49 +1368,44 @@ public class RbiReserveReturnApiResource {
}
/** Part C: compliance with section 24 — the liquid assets. */
private Map<String, Object> formOnePartC(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate slr) {
private Map<String, Object> formOnePartC(final List<LocalDate> dates, 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 (int i = 0; i < dates.size(); i++) {
final LocalDate date = dates.get(i);
final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId);
for (final LocalDate date : dates) {
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId);
final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
required.add(slrRequired);
// XII(a) is the cash reserve held OVER the section 18 requirement, plus the government
// securities of VII(c). Only the excess cash counts: the part of the cash reserve that
// meets section 18 is already committed and cannot also meet section 24.
//
// VII(c) alone, not the whole of VII. VII(a) and VII(b) are balances with the State
// Co-operative Bank and the District Central Co-operative Bank - deposits placed with
// other co-operative banks, which section 24 does not admit as liquid assets. Counting
// them here overstated what the bank held against SLR by the whole of those deposits.
// XII(a) is the cash reserve held OVER the section 18 requirement, plus the other
// balances of part VII. Only the excess counts: the part of the cash reserve that meets
// section 18 is already committed and cannot also meet section 24.
final BigDecimal crrRequired = formOnePartBRequired(date, officeId);
final BigDecimal crrMaintained = formOnePartBMaintained(date, officeId);
final BigDecimal excessCash = crrMaintained.subtract(crrRequired).max(BigDecimal.ZERO);
final BigDecimal governmentSecurities = figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount;
cashAndOther.add(excessCash.add(governmentSecurities));
final BigDecimal otherBalances = figureFor("FORM1_VII_A_STATE_COOP_OTHER", date, officeId).amount
.add(figureFor("FORM1_VII_B_DCCB_OTHER", date, officeId).amount)
.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount);
cashAndOther.add(excessCash.add(otherBalances));
gold.add(figureFor("FORMA_SLR_GOLD", date, officeId).amount);
securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount);
}
// Charged on IV(b) like the cash reserve above, and named the same way.
lines.add(formOneTotal("XI. " + (slr == null ? "SLR rate not set" : slr.percentage + " percent") + " (or a higher specified percentage) of IV as on the base date",
required));
lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED :"));
lines.add(formOneRow("(a) Cash and other balances maintained in India i.e. X-IX+VII :", null, cashAndOther, 1));
lines.add(formOneTotal("XI. " + (slr == null ? "SLR" : slr.percentage + "%")
+ " of IV as on the last Friday of the second preceding fortnight", 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));
lines.add(formOneRow("(c) Unencombered approved Securites $$", "SLR_INVESTMENTS", securities, 1));
lines.add(formOneRow("(c) Unencumbered approved securities", "SLR_INVESTMENTS", securities, 1));
final List<BigDecimal> totalXII = sumOf(cashAndOther, gold, securities);
lines.add(formOneTotal("Total of XII.....................................", totalXII));
lines.add(formOneTotal("TOTAL OF XII", totalXII));
final Map<String, Object> part = new LinkedHashMap<>();
part.put("title", "PART C : COMPLIANCE WITH SECTION 24 (Not applicable to scheduled state co-op. banks)");
part.put("title", "PART C — Compliance with Section 24");
part.put("lines", lines);
part.put("required", required);
part.put("maintained", totalXII);
@@ -1496,115 +1413,6 @@ 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) {
@@ -1625,47 +1433,23 @@ public class RbiReserveReturnApiResource {
}
/**
* The base date a column's ratios are charged on.
* The date both ratios are charged on: the last Friday of the SECOND PRECEDING fortnight.
*
* <p>
* 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.
* 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.
*
* <p>
* 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.
* Within that fortnight the form takes its last Friday, shifted off a holiday the same way
* every other reporting date is.
*/
private LocalDate formOneBaseDate(final LocalDate columnDate) {
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;
final LocalDate ownClose = fortnightCloseOnOrAfter(columnDate);
final LocalDate secondPrecedingClose = fortnightCloseBefore(fortnightCloseBefore(ownClose));
return precedingWorkingDay(reportingFridayOnOrBefore(secondPrecedingClose));
}
/**
@@ -1795,10 +1579,7 @@ public class RbiReserveReturnApiResource {
final LocalDate previousClose = requested.withDayOfMonth(1).minusDays(1);
final LocalDate base = formOneBaseDate(currentClose);
// 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 List<LocalDate> columnDates = List.of(previousClose, currentClose, base);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("formCode", "FORM_9_STATUTORY");
@@ -1809,7 +1590,6 @@ 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);
@@ -1819,11 +1599,8 @@ 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, 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));
response.put("partA", formOnePartA(columnDates, officeId, crr, slr));
response.put("partC", formOnePartC(columnDates, officeId, slr));
final List<String> unmet = new ArrayList<>();
if (slr == null) {
@@ -1843,9 +1620,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
+ ", 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 + " — 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.");
}
response.put("unmetRequirements", unmet);
return gson.toJson(response);
@@ -95,9 +95,7 @@ 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.
// 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),
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), 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
@@ -361,9 +361,6 @@ public class RecurringDepositAccountsApiResource {
} else if (is(commandParam, "close")) {
final CommandWrapper commandRequest = builder.closeRecurringDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "extendTenure")) {
final CommandWrapper commandRequest = builder.extendTenureRecurringDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "prematureClose")) {
final CommandWrapper commandRequest = builder.prematureCloseRecurringDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
@@ -380,7 +377,7 @@ public class RecurringDepositAccountsApiResource {
if (result == null) {
throw new UnrecognizedQueryParamException("command", commandParam,
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
"postInterest", "close", "prematureClose", "calculatePrematureAmount", "extendTenure" });
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
}
return this.toApiJsonSerializer.serialize(result);
@@ -1,205 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* RD Dashboard's data source: every Recurring Deposit still running (ACTIVE, or MATURED and waiting to be closed) in
* the user's office hierarchy, each with its installment position worked out as of the business date, plus the
* month's closures and penalty income for the summary cards.
*
* <p>
* An installment counts as paid by the deposit's cumulative deposits, not by the schedule's own completed flag - the
* same rule {@code RecurringDepositMissedInstallmentPenaltyService} uses, since money paid in advance at the counter is
* not always allocated to the installment it covers. The dashboard and the penalty job therefore agree on which
* installments are overdue.
*/
@Path("/v1/recurringdepositaccounts/dashboard")
@Component
@Tag(name = "Recurring Deposit Accounts", description = "RD Dashboard: running RDs with their installment position.")
@RequiredArgsConstructor
public class RecurringDepositDashboardApiResource {
/** DepositAccountType.RECURRING_DEPOSIT. */
private static final int RECURRING_DEPOSIT = 300;
/** SavingsAccountStatusType values. */
private static final int ACTIVE = 300;
private static final int CLOSED = 600;
private static final int PRE_MATURE_CLOSURE = 700;
private static final int MATURED = 800;
/** SavingsAccountTransactionType.DEPOSIT. */
private static final int DEPOSIT = 1;
private static final String ROWS_SQL = """
WITH rd AS (
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.account_balance_derived,
sa.auto_debit_facility, sa.linked_account_no, c.display_name AS client_name, o.id AS office_id,
o.name AS office_name
FROM m_savings_account sa
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ?
),
paid AS (
SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid,
SUM(t.amount) FILTER (WHERE t.transaction_date BETWEEN ? AND ?) AS paid_this_month
FROM m_savings_account_transaction t
JOIN rd ON rd.id = t.savings_account_id
WHERE t.transaction_type_enum = ? AND t.is_reversed = false
GROUP BY t.savings_account_id
),
sched AS (
SELECT s.savings_account_id AS id, s.duedate, s.deposit_amount,
SUM(s.deposit_amount) OVER (PARTITION BY s.savings_account_id ORDER BY s.duedate, s.installment) AS cum_due
FROM m_mandatory_savings_schedule s
JOIN rd ON rd.id = s.savings_account_id
),
pos AS (
SELECT sc.id,
COUNT(*) AS total_installments,
COUNT(*) FILTER (WHERE sc.cum_due <= COALESCE(p.total_paid, 0)) AS paid_installments,
MIN(sc.duedate) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0)) AS next_unpaid_due,
COUNT(*) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0) AND sc.duedate < ?) AS overdue_installments,
GREATEST(COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate < ?), 0) - COALESCE(MAX(p.total_paid), 0), 0)
AS overdue_amount,
COUNT(*) FILTER (WHERE sc.duedate = ?) AS due_today_count,
COUNT(*) FILTER (WHERE sc.duedate = ? AND sc.cum_due <= COALESCE(p.total_paid, 0)) AS due_today_paid,
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?), 0) AS due_this_month,
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?
AND sc.cum_due <= COALESCE(p.total_paid, 0)), 0) AS received_this_month
FROM sched sc
LEFT JOIN paid p ON p.id = sc.id
GROUP BY sc.id
),
pen AS (
SELECT pe.savings_account_id AS id, COUNT(*) AS penalty_count, SUM(pe.penalty_amount) AS penalty_amount
FROM m_rd_installment_penalty pe
JOIN rd ON rd.id = pe.savings_account_id
GROUP BY pe.savings_account_id
),
lien AS (
SELECT paa.linked_savings_account_id AS id, SUM(COALESCE(gfd.amount_remaining_derived, gfd.amount)) AS lien_amount
FROM m_guarantor_funding_details gfd
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
JOIN m_loan l ON l.id = paa.loan_account_id
JOIN rd ON rd.id = paa.linked_savings_account_id
WHERE gfd.status_enum = 100 AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
GROUP BY paa.linked_savings_account_id
)
SELECT rd.id AS "id", rd.account_no AS "accountNo", rd.client_id AS "clientId", rd.client_name AS "clientName",
rd.office_id AS "officeId", rd.office_name AS "officeName", sp.id AS "productId", sp.name AS "productName",
rd.status_enum AS "status", rd.account_balance_derived AS "balance",
rdd.mandatory_recommended_deposit_amount AS "installmentAmount",
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate", datp.maturity_amount AS "maturityAmount",
COALESCE(rd.auto_debit_facility, false) AS "autoDebit", rd.linked_account_no AS "linkedAccountNo",
COALESCE(sp.missed_installment_grace_days, 0) AS "graceDays",
COALESCE(sp.penalty_grace_period_missed_installments, false) AS "penaltyEnabled",
COALESCE(pos.total_installments, 0) AS "totalInstallments",
COALESCE(pos.paid_installments, 0) AS "paidInstallments",
TO_CHAR(pos.next_unpaid_due, 'YYYY-MM-DD') AS "nextDueDate",
COALESCE(pos.overdue_installments, 0) AS "overdueInstallments",
COALESCE(pos.overdue_amount, 0) AS "overdueAmount",
COALESCE(pos.due_today_count, 0) AS "dueTodayCount",
COALESCE(pos.due_today_paid, 0) AS "dueTodayPaid",
COALESCE(pos.due_this_month, 0) AS "dueThisMonth",
COALESCE(pos.received_this_month, 0) AS "receivedThisMonth",
COALESCE(p.paid_this_month, 0) AS "paidThisMonth",
COALESCE(pen.penalty_count, 0) AS "penaltyCount",
COALESCE(pen.penalty_amount, 0) AS "penaltyAmount",
COALESCE(lien.lien_amount, 0) AS "lienAmount"
FROM rd
JOIN m_savings_product sp ON sp.id = rd.product_id
LEFT JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = rd.id
LEFT JOIN m_deposit_account_recurring_detail rdd ON rdd.savings_account_id = rd.id
LEFT JOIN pos ON pos.id = rd.id
LEFT JOIN paid p ON p.id = rd.id
LEFT JOIN pen ON pen.id = rd.id
LEFT JOIN lien ON lien.id = rd.id
ORDER BY pos.next_unpaid_due NULLS LAST, rd.account_no
""";
/** RDs closed this month, split by how they ended. */
private static final String CLOSED_THIS_MONTH_SQL = """
SELECT COUNT(*) FILTER (WHERE sa.status_enum = ?) AS matured_closed,
COUNT(*) FILTER (WHERE sa.status_enum = ?) AS premature_closed
FROM m_savings_account sa
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
WHERE sa.deposit_type_enum = ? AND sa.closedon_date BETWEEN ? AND ? AND o.hierarchy LIKE ?
""";
/** Missed-installment penalties charged, this month and in all, on RDs in the hierarchy (closed ones included). */
private static final String PENALTY_INCOME_SQL = """
SELECT COALESCE(SUM(pe.penalty_amount) FILTER (WHERE pe.applied_on_date BETWEEN ? AND ?), 0) AS this_month,
COALESCE(SUM(pe.penalty_amount), 0) AS total
FROM m_rd_installment_penalty pe
JOIN m_savings_account sa ON sa.id = pe.savings_account_id
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
WHERE o.hierarchy LIKE ?
""";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String retrieve() {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
// "Today" is the business date - the branch's working day, not the server clock.
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate monthStart = today.withDayOfMonth(1);
final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy,
monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd);
final Map<String, Object> closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE,
RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy);
final Map<String, Object> penalty = this.jdbcTemplate.queryForMap(PENALTY_INCOME_SQL, monthStart, monthEnd, hierarchy);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("businessDate", today.toString());
result.put("maturedClosedThisMonth", closed.get("matured_closed"));
result.put("prematureClosedThisMonth", closed.get("premature_closed"));
result.put("penaltyIncomeThisMonth", penalty.get("this_month"));
result.put("penaltyIncomeTotal", penalty.get("total"));
result.put("accounts", rows);
return this.gson.toJson(result);
}
}
@@ -1,80 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
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.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
import org.springframework.stereotype.Component;
/**
* Read-only: the loans a recurring deposit currently secures and how much of it each one holds, plus how much more
* could be given as loan against it today, so the RD screens can show the lien, its limit, and why the deposit cannot
* be closed.
*/
@Path("/v1/recurringdepositaccounts/{accountId}/liens")
@Component
@Tag(name = "Recurring Deposit Account", description = "Liens on a recurring deposit pledged as loan security.")
@RequiredArgsConstructor
public class RecurringDepositLienApiResource {
private final PlatformSecurityContext context;
private final RecurringDepositLienService recurringDepositLienService;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String retrieve(@PathParam("accountId") final Long accountId) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final List<RecurringDepositLienService.Lien> liens = this.recurringDepositLienService.activeLiens(accountId);
final Map<String, Object> result = new LinkedHashMap<>();
final BigDecimal totalLien = liens.stream().map(RecurringDepositLienService.Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
result.put("totalLienAmount", totalLien);
final RecurringDepositLienService.LienLimit limit = this.recurringDepositLienService.lienLimit(accountId);
if (limit != null) {
result.put("lienAllowed", limit.lienAllowed());
result.put("lienPercentage", limit.accountPercentage());
result.put("productLienPercentage", limit.productPercentage());
result.put("effectiveLienPercentage", limit.effectivePercentage());
result.put("maxLoanAmount", limit.maxLoanAmount());
result.put("availableForLoan", limit.maxLoanAmount().subtract(totalLien).max(BigDecimal.ZERO));
}
result.put("liens", liens.stream().map(lien -> {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("loanId", lien.loanId());
row.put("loanAccountNo", lien.loanAccountNo());
row.put("loanStatus", lien.loanStatus());
row.put("amount", lien.amount());
return row;
}).toList());
return this.gson.toJson(result);
}
}
@@ -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, 18) THEN -t.amount
WHEN t.transaction_type_enum IN (2, 4, 5, 7) 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, 18) THEN -t.amount
WHEN t.transaction_type_enum IN (2, 4, 5, 7) 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, 18) THEN -t.amount
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
ELSE 0 END), 0)
FROM m_savings_account_transaction t
WHERE t.savings_account_id = ?
@@ -180,7 +180,6 @@ public class DepositProductDataValidator {
}
validateRecurringDetailForCreate(element, baseDataValidator);
validateLienPercentage(element, baseDataValidator);
if (!hasRdSlabs) {
validateChartsData(element, baseDataValidator);
@@ -216,7 +215,6 @@ public class DepositProductDataValidator {
}
validateRecurringDepositUpdate(element, baseDataValidator);
validateLienPercentage(element, baseDataValidator);
// RD products now use the CBS base chart as a read-only template, so
// product updates no longer require a charts payload.
@@ -227,40 +225,6 @@ public class DepositProductDataValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/** Lien Marked's loan limit: a share of the deposit's balance, so above 0 and at most 100. Optional. */
private void validateLienPercentage(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
if (!this.fromApiJsonHelper.parameterExists("lienPercentage", element)) {
return;
}
final BigDecimal lienPercentage = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("lienPercentage", element);
baseDataValidator.reset().parameter("lienPercentage").value(lienPercentage).ignoreIfNull().positiveAmount()
.notGreaterThanMax(BigDecimal.valueOf(100));
validateMissedInstallmentPenalty(element, baseDataValidator);
}
/**
* Missed-installment penalty: grace days are whole days from 0, the type is 1 (flat amount) or 2 (% of the
* installment), and the amount is positive - at most 100 when it is a percentage. All optional.
*/
private void validateMissedInstallmentPenalty(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
if (this.fromApiJsonHelper.parameterExists("missedInstallmentGraceDays", element)) {
final Integer graceDays = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentGraceDays", element);
baseDataValidator.reset().parameter("missedInstallmentGraceDays").value(graceDays).ignoreIfNull().integerZeroOrGreater();
}
Integer penaltyType = null;
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyType", element)) {
penaltyType = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentPenaltyType", element);
baseDataValidator.reset().parameter("missedInstallmentPenaltyType").value(penaltyType).ignoreIfNull().isOneOfTheseValues(1, 2);
}
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyAmount", element)) {
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("missedInstallmentPenaltyAmount", element);
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).ignoreIfNull().positiveAmount();
if (amount != null && Integer.valueOf(2).equals(penaltyType)) {
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).notGreaterThanMax(BigDecimal.valueOf(100));
}
}
}
private void validateDepositDetailForCreate(final JsonElement element, final FromJsonHelper fromApiJsonHelper,
final DataValidatorBuilder baseDataValidator, final DepositAccountType accountType) {
final String name = fromApiJsonHelper.extractStringNamed(nameParamName, element);
@@ -67,7 +67,4 @@ public interface DepositAccountDomainService {
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
/** Names the journal entries of the recurring deposit's interest credits that have no narration yet. */
void narrateRdInterestPostings(RecurringDepositAccount account);
}
@@ -72,7 +72,6 @@ import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -80,7 +79,6 @@ 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;
@@ -105,7 +103,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final JdbcTemplate jdbcTemplate;
private final SavingsInterestTdsService savingsInterestTdsService;
@Transactional
@Override
@@ -280,13 +277,9 @@ 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);
@@ -407,13 +400,10 @@ 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)
@@ -448,47 +438,14 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
Long savingsTransactionId = null;
// Checked before anything is posted or paid out, so a wrong date is reported as such.
account.validateMaturityClosureDate(closedDate);
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, closedDate, postReversals);
// Paid out late: the days between maturity and today earn the product's post-maturity rate, so every closure
// branch below - reinvest, transfer or withdraw - carries it.
account.postOverdueMaturityInterest(closedDate, postReversals);
final BigDecimal transactionAmount = account.getAccountBalance();
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
BigDecimal reInvestAmount;
if (onClosureType.isReinvestPrincipal()) {
// getDepositAmount() is the deposit's contracted total (every installment it was meant to receive),
// not what actually came in. Capped at the balance, or a deposit with missed installments - or none -
// would open its successor with money that was never paid.
reInvestAmount = account.getDepositAmount() == null ? account.getAccountBalance()
: account.getDepositAmount().min(account.getAccountBalance());
// Principal only: the interest part does not go into the new deposit, so it has to be paid
// somewhere real first - the savings account named on the request, else the one chosen when
// the deposit was opened, else its linked savings account. Previously the full balance was
// withdrawn while only the principal was redeposited, so the interest simply vanished.
final BigDecimal interestPart = account.getAccountBalance().subtract(reInvestAmount);
if (interestPart.signum() > 0) {
final Long interestTargetId = resolveRdPayoutSavingsId(account,
command.longValueOfParameterNamed(toSavingsAccountIdParamName));
if (interestTargetId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.reinvest.principal.needs.savings",
"Reinvest Principal Only pays the interest out to a savings account - choose one, or link a Savings account to this deposit.");
}
final SavingsAccount interestTarget = this.depositAccountAssembler.assembleFrom(interestTargetId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final String description = "RD " + account.getAccountNumber() + " interest to savings "
+ interestTarget.getAccountNumber() + " (maturity, principal reinvested)";
final AccountTransferDTO interestTransfer = new AccountTransferDTO(closedDate, interestPart, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null, null,
AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, interestTarget, account,
isRegularTransaction, isExceptionForBalanceCheck);
narrateTransfer(this.accountTransfersWritePlatformService.transferFunds(interestTransfer), description);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
}
reInvestAmount = account.getDepositAmount();
} else {
reInvestAmount = account.getAccountBalance();
}
@@ -504,44 +461,31 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
frequency = frequency == -1 ? 1 : frequency;
reinvestedDeposit.generateSchedule(frequencyType, frequency, calendar);
reinvestedDeposit.processAccountUponActivation(fmt, postReversals, relaxingDaysConfigForPivotDate);
// The opening credit is on the new deposit but its stored balance was never recomputed, so it read 0
// until something else touched the account.
reinvestedDeposit.updateSavingsAccountSummary(reinvestedDeposit.getTransactions());
reinvestedDeposit.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
this.savingsAccountRepository.save(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// The money moves deposit-to-deposit and never touches cash: the new deposit's opening credit and the
// old deposit's withdrawal below both post against transfers-in-suspense, which nets to nil. Before
// this the opening credit posted no journal entry at all and the withdrawal posted as cash paid out,
// leaving the RD control GL short and cash overstated by the whole reinvested amount.
final boolean isInternalTransferLeg = true;
postJournalEntries(reinvestedDeposit, new HashSet<>(), new HashSet<>(), isInternalTransferLeg);
final boolean applyWithdrawFee = false;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, applyWithdrawFee, isRegularTransaction, isInternalTransferLeg);
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
// The withdrawal posted journal entries for everything new on the account (the maturity interest
// included), so every transaction now on it is accounted for.
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "maturity", transferDescription, locale, fmt,
isRegularTransaction);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, transactionAmount,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
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);
@@ -549,28 +493,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
narrateRdInterestPostings(account);
return savingsTransactionId;
}
/**
* Where a recurring deposit's proceeds may be paid when no account was named for this closure: the savings
* account chosen when it was opened, else its own linked Savings account (Deposit Account Link).
*/
private Long resolveRdPayoutSavingsId(final RecurringDepositAccount account, final Long requestedSavingsId) {
if (requestedSavingsId != null) {
return requestedSavingsId;
}
if (account.getTransferToSavingsAccountId() != null) {
return account.getTransferToSavingsAccountId();
}
if (account.getLinkedAccountId() != null && "SAVINGS".equalsIgnoreCase(account.getLinkedAccountType())) {
return account.getLinkedAccountId();
}
return null;
}
private CalendarInstance getCalendarInstance(final RecurringDepositAccount account, RecurringDepositAccount reinvestedDeposit) {
CalendarInstance calendarInstance = null;
CalendarInstance parentCalendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(
@@ -929,9 +855,6 @@ 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
@@ -942,19 +865,15 @@ 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);
@@ -1215,13 +1134,10 @@ 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);
@@ -1259,9 +1175,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
Long savingsTransactionId = null;
// The reason decides the penalty (and, for a deceased holder, lock-in) before any interest is worked out.
account.applyPrematureClosureReason(
PrematureClosureReasonType.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName)));
// post interest
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postReversals);
@@ -1270,51 +1183,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
DepositAccountOnClosureType closureType = DepositAccountOnClosureType.fromInt(closureTypeValue);
if (closureType.isTransferToSavings()) {
final boolean isExceptionForBalanceCheck = false;
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "premature closure", transferDescription, locale, fmt,
isRegularTransaction);
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(),
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
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);
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
narrateRdInterestPostings(account);
return savingsTransactionId;
}
/**
* Names the journal entries of every interest credit on the recurring deposit that has none yet - the term's
* interest posted at maturity or premature closure, and any post-maturity interest - so the ledger shows whose
* interest it is. Called once the closure's own entries are posted, which is the last of them to be written.
*/
@Override
public void narrateRdInterestPostings(final RecurringDepositAccount account) {
final String narration = "RD " + account.getAccountNumber() + " interest credited";
narrateJournalEntries("""
SELECT id FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
""", narration, account.getId(), SavingsAccountTransactionType.INTEREST_POSTING.getValue());
// An interest credit taken back (a premature closure, or a maturity whose final rate came out lower, credits
// the recalculated interest instead): its original entries are already named, so this names only the reversing
// entries.
narrateJournalEntries("""
SELECT id FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = true
""", "RD " + account.getAccountNumber() + " interest reversed - recalculated on closure", account.getId(),
SavingsAccountTransactionType.INTEREST_POSTING.getValue());
}
private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(account.findExistingTransactionIds());
@@ -1331,76 +1222,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
}
/**
* Pays a closing recurring deposit into a savings account as two credits - its interest, then the principal -
* each described with the deposit's account number, so the savings statement shows which RD the money came from
* and how much of it is interest.
*
* <p>
* The interest goes first and as an interest transfer: TDS counts as interest only what a payout carries beyond
* the interest already swept out of the deposit, so with the interest swept first the principal transfer after it
* is never mistaken for interest, and the interest is tracked for TDS on its own credit.
*/
private void transferRdPayoutToSavings(final RecurringDepositAccount account, final SavingsAccount toSavingsAccount,
final LocalDate closedDate, final String closureLabel, final String clerkNote, final Locale locale,
final DateTimeFormatter fmt, final boolean isRegularTransaction) {
final boolean isExceptionForBalanceCheck = false;
final String note = clerkNote == null || clerkNote.isBlank() ? "" : " - " + clerkNote.trim();
final String rd = "RD " + account.getAccountNumber();
final BigDecimal interest = account.getInterestInBalance();
final BigDecimal principal = account.getAccountBalance().subtract(interest);
if (interest.signum() > 0) {
final String description = rd + " interest to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")" + note;
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, interest,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
null, AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isRegularTransaction, isExceptionForBalanceCheck));
narrateTransfer(transferId, description);
}
if (principal.signum() > 0) {
final String description = rd + " principal to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")"
+ note;
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, principal,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isRegularTransaction, isExceptionForBalanceCheck));
narrateTransfer(transferId, description);
}
}
/**
* Writes a narration onto the journal entries of both sides of a transfer. The entries are posted while the
* transfer is still being made, before its own record - and so its description - exists, so they are named here
* once it does.
*/
private void narrateTransfer(final Long transferDetailsId, final String narration) {
if (transferDetailsId == null) {
return;
}
narrateJournalEntries("""
SELECT from_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
""", narration, transferDetailsId, transferDetailsId);
}
/** Sets the narration of the not-yet-narrated journal entries of the savings transactions the query returns. */
private void narrateJournalEntries(final String transactionIdsSql, final String narration, final Object... args) {
final List<Long> transactionIds = this.jdbcTemplate.queryForList(transactionIdsSql, Long.class, args);
for (final Long transactionId : transactionIds) {
if (transactionId != null) {
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET description = ? WHERE savings_transaction_id = ? AND (description IS NULL OR description = '')",
narration, transactionId);
}
}
}
/** 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),
@@ -1,62 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.jobs.applyrdmissedinstallmentpenalty;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.savings.service.RecurringDepositMissedInstallmentPenaltyService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
/**
* Runs the RD missed-installment penalty. The service decides what is due and isolates each installment, so the step
* is a thin trigger and the rules live in one place.
*/
@Configuration
public class ApplyRdMissedInstallmentPenaltyJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private RecurringDepositMissedInstallmentPenaltyService recurringDepositMissedInstallmentPenaltyService;
@Bean
protected Step applyRdMissedInstallmentPenaltyStep() {
return new StepBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).tasklet((contribution, chunkContext) -> {
recurringDepositMissedInstallmentPenaltyService.applyPenalties();
return RepeatStatus.FINISHED;
}, transactionManager).build();
}
@Bean
public Job applyRdMissedInstallmentPenaltyJob() {
return new JobBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).start(applyRdMissedInstallmentPenaltyStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -1,60 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.jobs.executerdautodebit;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ExecuteRdAutoDebitJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private RecurringDepositAutoDebitService recurringDepositAutoDebitService;
@Bean
public ExecuteRdAutoDebitTasklet executeRdAutoDebitTasklet() {
return new ExecuteRdAutoDebitTasklet(recurringDepositAutoDebitService);
}
@Bean
protected Step executeRdAutoDebitStep() {
return new StepBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).tasklet(executeRdAutoDebitTasklet(), transactionManager)
.build();
}
@Bean
public Job executeRdAutoDebitJob() {
return new JobBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).start(executeRdAutoDebitStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -1,42 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.jobs.executerdautodebit;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Runs RD Auto Debit. The service decides what is due and isolates each installment, so the tasklet stays a thin
* trigger and the rules live in one place.
*/
@RequiredArgsConstructor
public class ExecuteRdAutoDebitTasklet implements Tasklet {
private final RecurringDepositAutoDebitService service;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
service.collectDueInstallments();
return RepeatStatus.FINISHED;
}
}
@@ -61,16 +61,6 @@ public class UpdateDepositsAccountMaturityDetailsTasklet implements Tasklet {
}
}
// Recurring deposits still running: credit any posting period (monthly, quarterly... per product) that ended
// before today. The query above only returns deposits that have reached maturity.
for (final Long rdId : depositAccountReadPlatformService.retrieveRunningRecurringDepositIds()) {
try {
depositAccountWritePlatformService.postRecurringDepositPeriodicInterest(rdId);
} catch (final Exception ex) {
log.error("Periodic interest posting failed for recurring deposit id {}", rdId, ex);
}
}
log.debug("{}: Records affected by updateMaturityDetailsOfDepositAccounts: {}", ThreadLocalContextUtil.getTenant().getName(),
depositAccounts.size());
return RepeatStatus.FINISHED;
@@ -46,7 +46,6 @@ import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
@RequiredArgsConstructor
public class DepositAccountPreMatureCalculationPlatformServiceImpl implements DepositAccountPreMatureCalculationPlatformService {
@@ -118,19 +117,12 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
} else if (depositAccountType.isRecurringDeposit()) {
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
// Same reason rules the real closure applies, so the preview pays exactly what the closure will.
rd.applyPrematureClosureReason(prematureClosureReason);
accountData = RecurringDepositAccountData.preClosureDetails(
account.getId(), rd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
}
// A preview, not a closure: working the payout out resets the account's own interest rate to the premature
// rate and refreshes its interest-earned summary on the managed entity, and committing that left an RD that
// was only previewed showing the penalty rate (4.5% instead of 6.5%) as if it had been broken. Nothing the
// calculation touched may be saved.
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return accountData;
}
}
@@ -61,9 +61,6 @@ public interface DepositAccountReadPlatformService {
Collection<DepositAccountData> retrieveForMaturityUpdate();
/** Active recurring deposits that have not reached maturity yet - their posting periods are credited nightly. */
Collection<Long> retrieveRunningRecurringDepositIds();
SavingsAccountTransactionData retrieveRecurringAccountDepositTransactionTemplate(Long accountId);
Collection<AccountTransferDTO> retrieveDataForInterestTransfer();
@@ -283,18 +283,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
return this.jdbcTemplate.query(sqlBuilder.toString(), DEPOSIT_ACCOUNT_LOOKUP_MAPPER, depositAccountType.getValue());
}
@Override
public Collection<Long> retrieveRunningRecurringDepositIds() {
return this.jdbcTemplate.queryForList("""
SELECT da.id FROM m_savings_account da
JOIN m_deposit_account_term_and_preclosure dat ON dat.savings_account_id = da.id
WHERE da.deposit_type_enum = ? AND da.status_enum = ?
AND (dat.maturity_date IS NULL OR dat.maturity_date > ?)
ORDER BY da.id
""", Long.class, DepositAccountType.RECURRING_DEPOSIT.getValue(), SavingsAccountStatusType.ACTIVE.getValue(),
DateUtils.getBusinessLocalDate());
}
@Override
public Collection<DepositAccountData> retrieveForMaturityUpdate() {
@@ -2069,8 +2057,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
sqlBuilder.append("dptp.max_deposit_term_type_enum as maxDepositTermTypeId, ");
sqlBuilder.append("dptp.in_multiples_of_deposit_term as inMultiplesOfDepositTerm, ");
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
// Lien Marked's loan limit, so the opening screen can default to it and refuse anything above it.
sqlBuilder.append("sa.lien_percentage as lienPercentage, ");
sqlBuilder.append("sa.account_type as accountType, sa.customer_segment as customerSegment, ");
sqlBuilder.append("sa.product_code as productCode, ");
sqlBuilder.append("sa.effective_date as effectiveDate, sa.effective_to as effectiveTo, sa.end_date as endDate, ");
@@ -2197,7 +2183,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
rs.getBoolean("deceasedSettlement"),
rs.getBoolean("allowDebitTxnAfterDueDate")
);
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
return result;
}
@@ -24,7 +24,6 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.amountPa
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargeIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
@@ -45,7 +44,6 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
@@ -149,8 +147,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final JdbcTemplate jdbcTemplate;
private final RecurringDepositLienService recurringDepositLienService;
private final RecurringDepositProductRules recurringDepositProductRules;
@Transactional
@Override
@@ -643,12 +639,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
checkClientOrGroupActive(account);
if (account instanceof RecurringDepositAccount rdAccount) {
// A recurring deposit is credited by its posting periods only - the same credits the nightly job makes.
postRecurringDepositPeriodicInterest(rdAccount);
} else {
postInterest(account);
}
postInterest(account);
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
@@ -659,28 +650,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public void postRecurringDepositPeriodicInterest(final Long recurringDepositId) {
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(recurringDepositId,
DepositAccountType.RECURRING_DEPOSIT);
postRecurringDepositPeriodicInterest(account);
}
/**
* Credits a recurring deposit's completed posting periods (monthly, quarterly... per its product) with their own
* journal entries, named after the deposit.
*/
private void postRecurringDepositPeriodicInterest(final RecurringDepositAccount rdAccount) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(rdAccount, existingTransactionIds, existingReversedTransactionIds);
rdAccount.postPeriodicInterest(this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth(), false);
this.savingAccountRepositoryWrapper.saveAndFlush(rdAccount);
postJournalEntries(rdAccount, existingTransactionIds, existingReversedTransactionIds);
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
}
private void postInterest(final SavingsAccount account) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
@@ -962,7 +931,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.RECURRING_DEPOSIT);
checkClientOrGroupActive(account);
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed");
this.depositAccountDomainService.handleRDAccountClosure(account, paymentDetail, user, command, changes);
@@ -1079,43 +1047,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public CommandProcessingResult extendRecurringDepositTenure(final Long savingsId, final JsonCommand command) {
this.context.authenticatedUser();
final Integer newDepositPeriod = command.integerValueOfParameterNamed(DepositsApiConstants.depositPeriodParamName);
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.RECURRING_DEPOSIT);
checkClientOrGroupActive(account);
// The added installments follow the deposit's own collection calendar (monthly unless it was opened otherwise).
PeriodFrequencyType frequencyType = PeriodFrequencyType.MONTHS;
Integer frequency = 1;
final CalendarInstance calendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(savingsId,
CalendarEntityType.SAVINGS.getValue(), CalendarType.COLLECTION.getValue());
if (calendarInstance != null && calendarInstance.getCalendar() != null) {
final String recurrence = calendarInstance.getCalendar().getRecurrence();
frequencyType = CalendarFrequencyType.from(CalendarUtils.getFrequency(recurrence));
frequency = CalendarUtils.getInterval(recurrence);
frequency = frequency == -1 ? 1 : frequency;
}
final Map<String, Object> changes = account.extendTenure(newDepositPeriod, frequencyType, frequency,
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
this.recurringDepositProductRules.validateMaturityWithinProductEndDate(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
.withClientId(account.clientId()) //
.withGroupId(account.groupId()) //
.withSavingsId(savingsId) //
.with(changes) //
.build();
}
@Override
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
@@ -1128,7 +1059,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.RECURRING_DEPOSIT);
checkClientOrGroupActive(account);
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed prematurely");
if (account.maturityDate() == null) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
@@ -1659,76 +1589,11 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
}
} else if (depositAccountType.isRecurringDeposit()) {
final RecurringDepositAccount rdAccount = (RecurringDepositAccount) account;
// Every night: credit any posting period (the product's Interest Posting Period) that ended before today.
rdAccount.postPeriodicInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
rdAccount.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
final JsonCommand maturityClosure = rdMaturityInstructionCommand(rdAccount);
if (maturityClosure != null) {
// Same order as the FD branch above: give the maturity interest its own journal entry first and
// mark it posted, so neither the closure's withdrawal nor the final call below posts it again.
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
this.depositAccountDomainService.handleRDAccountClosure(rdAccount, null, this.context.authenticatedUser(),
maturityClosure, new HashMap<>());
// The closure posted its own entries for everything it created.
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
}
((RecurringDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postReversals);
}
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
if (account instanceof RecurringDepositAccount rdAccount) {
// The term's interest credited on the maturity date, named in the ledger as soon as it is posted.
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
}
}
/**
* The close command that carries out a recurring deposit's maturity instruction tonight, or null when it must
* wait for staff instead. It runs only when the product has Maturity Instructions on, the deposit has just
* matured, it was opened with Transfer to Savings or a Reinvest option, nothing holds a lien on it, and - for a
* transfer - there is a savings account to pay into (the one chosen at opening, else the linked Savings account).
* Anything else stays Matured, earning the post-maturity rate until someone closes it.
*/
private JsonCommand rdMaturityInstructionCommand(final RecurringDepositAccount rdAccount) {
if (!rdAccount.isMatured() || rdAccount.savingsProduct() == null || !rdAccount.savingsProduct().isMaturityInstructions()) {
return null;
}
final DepositAccountOnClosureType instruction = DepositAccountOnClosureType.fromInt(rdAccount.getOnAccountClosureId());
if (!(instruction.isTransferToSavings() || instruction.isReinvest()) || instruction.isPartialReinvest()) {
return null;
}
if (rdAccount.getAccountBalance() == null || rdAccount.getAccountBalance().signum() <= 0) {
// Nothing was ever paid in: there is nothing to transfer or reinvest, so a person has to look at it.
log.info("RD {} matured with no balance; maturity instruction left for staff", rdAccount.getAccountNumber());
return null;
}
if (!this.recurringDepositLienService.activeLiens(rdAccount.getId()).isEmpty()) {
log.info("RD {} matured with a lien on it; maturity instruction left for staff", rdAccount.getAccountNumber());
return null;
}
Long payoutSavingsId = rdAccount.getTransferToSavingsAccountId();
if (payoutSavingsId == null && rdAccount.getLinkedAccountId() != null
&& "SAVINGS".equalsIgnoreCase(rdAccount.getLinkedAccountType())) {
payoutSavingsId = rdAccount.getLinkedAccountId();
}
// Transfer always needs a destination; Reinvest Principal Only needs one for the interest part.
if (payoutSavingsId == null && (instruction.isTransferToSavings() || instruction.isReinvestPrincipal())) {
log.warn("RD {} matured with instruction {} but no savings account to pay into; left for staff", rdAccount.getAccountNumber(),
instruction.getCode());
return null;
}
final JsonObject closure = new JsonObject();
closure.addProperty(SavingsApiConstants.closedOnDateParamName, rdAccount.maturityDate().toString());
closure.addProperty(SavingsApiConstants.dateFormatParamName, "yyyy-MM-dd");
closure.addProperty(SavingsApiConstants.localeParamName, "en");
closure.addProperty(DepositsApiConstants.onAccountClosureIdParamName, instruction.getValue());
if (payoutSavingsId != null) {
closure.addProperty(DepositsApiConstants.toSavingsAccountIdParamName, payoutSavingsId);
}
closure.addProperty(DepositsApiConstants.transferDescriptionParamName, "Apply maturity instructions");
return JsonCommand.fromJsonElement(rdAccount.getId(), closure, new FromJsonHelper());
}
@Transactional
@@ -127,7 +127,6 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
private final BusinessEventNotifierService businessEventNotifierService;
private final SavingsAccountAssembler savingsAccountAssembler;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final RecurringDepositProductRules recurringDepositProductRules;
/*
* Guaranteed to throw an exception no matter what the data integrity issue is.
@@ -303,12 +302,9 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
frequency = frequency == -1 ? 1 : frequency;
account.generateSchedule(frequencyType, frequency, calendar);
final boolean isPreMatureClosure = false;
// The simple/compounding flag must be on the account before its maturity is projected.
this.recurringDepositProductRules.applyInterestCalculationType(account);
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
account.validateApplicableInterestRate();
this.recurringDepositProductRules.validateOnSubmit(account, command);
savingAccountRepository.save(account);
// Save nominees if provided in payload (same transaction)
@@ -555,15 +551,12 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
frequency = frequency == -1 ? 1 : frequency;
account.generateSchedule(frequencyType, frequency, calendar);
final boolean isPreMatureClosure = false;
// An application is not yet a contract, so an edit (possibly to a different product) re-reads it.
this.recurringDepositProductRules.applyInterestCalculationType(account);
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
account.validateApplicableInterestRate();
this.savingAccountRepository.save(account);
}
this.recurringDepositProductRules.validateOnModify(account, command);
// update calendar details
if (!account.isCalendarInherited()) {
@@ -499,10 +499,6 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("sp.balance_frequency as balanceFrequency, ");
sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, sp.msme as msme, ");
sqlBuilder.append("sp.lien_percentage as lienPercentage, ");
sqlBuilder.append("sp.missed_installment_grace_days as missedInstallmentGraceDays, ");
sqlBuilder.append("sp.missed_installment_penalty_type as missedInstallmentPenaltyType, ");
sqlBuilder.append("sp.missed_installment_penalty_amount as missedInstallmentPenaltyAmount, ");
sqlBuilder.append("sp.auto_debit_facility as autoDebitFacility, sp.loan_facility as loanFacility, ");
sqlBuilder.append("sp.premature_withdrawal_option as prematureWithdrawalOption, sp.tenure_flexibility as tenureFlexibility, ");
sqlBuilder.append("sp.lien_marked as lienMarked, sp.maturity_instructions as maturityInstructions, ");
@@ -627,10 +623,6 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
result.setPrematureWithdrawalOption(prematureWithdrawalOption);
result.setTenureFlexibility(tenureFlexibility);
result.setLienMarked(lienMarked);
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
result.setMissedInstallmentGraceDays(JdbcSupport.getInteger(rs, "missedInstallmentGraceDays"));
result.setMissedInstallmentPenaltyType(JdbcSupport.getInteger(rs, "missedInstallmentPenaltyType"));
result.setMissedInstallmentPenaltyAmount(rs.getBigDecimal("missedInstallmentPenaltyAmount"));
result.setMaturityInstructions(maturityInstructions);
result.setCreditMaturityProceeds(creditMaturityProceeds);
result.setPenaltyGracePeriodMissedInstallments(penaltyGracePeriodMissedInstallments);
@@ -29,47 +29,19 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonQuery;
import org.apache.fineract.infrastructure.core.domain.LocalDateInterval;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.domain.SavingsHelper;
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@RequiredArgsConstructor
public class RecurringDepositAccountInterestCalculationServiceImpl implements RecurringDepositAccountInterestCalculationService {
private static final String PRODUCT_ID_PARAM = "productId";
private final FromJsonHelper fromApiJsonHelper;
private final JdbcTemplate jdbcTemplate;
private final SavingsHelper savingsHelper;
private final ConfigurationDomainService configurationDomainService;
private final RecurringDepositProductRules recurringDepositProductRules;
/** The interest settings of an RD product, as an account opened under it will be calculated with. */
private record ProductInterestTerms(SavingsCompoundingInterestPeriodType compounding, SavingsPostingInterestPeriodType posting,
SavingsInterestCalculationType calculationType, SavingsInterestCalculationDaysInYearType daysInYear, boolean simpleInterest,
BigDecimal minBalanceForInterest, MonetaryCurrency currency) {}
@Override
public HashMap<String, Object> calculateInterest(JsonQuery query) {
@@ -81,120 +53,27 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
element);
LocalDate submittedOnDate = this.fromApiJsonHelper.extractLocalDateNamed("submittedOnDate", element);
if (submittedOnDate == null) {
submittedOnDate = DateUtils.getBusinessLocalDate();
submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
final Long productId = this.fromApiJsonHelper.extractLongNamed(PRODUCT_ID_PARAM, element);
final ProductInterestTerms terms = productId == null ? null : productInterestTerms(productId);
final HashMap<String, Object> result = new HashMap<>();
BigDecimal maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
interestCompoundingPeriodInMonths, submittedOnDate);
BigDecimal totalPrincipal = depositAmount.multiply(BigDecimal.valueOf(tenureInMonths));
BigDecimal maturityAmount;
if (terms != null) {
// The product's own settings, through the same engine an opened account is calculated with - so the figure
// shown before opening is the maturity amount the account will carry.
final List<Map<String, Object>> postings = new ArrayList<>();
final BigDecimal totalInterest = projectInterest(terms, depositAmount, annualInterestRate, tenureInMonths.intValue(),
submittedOnDate, postings);
maturityAmount = totalPrincipal.add(totalInterest);
result.put("interestCalculation", terms.simpleInterest() ? "Simple" : "Compounding");
result.put("compoundingPeriod", terms.compounding().name());
result.put("postingPeriod", terms.posting().name());
result.put("postingSchedule", postings);
result.put("daysInYear", terms.daysInYear().getValue());
} else {
maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
interestCompoundingPeriodInMonths, submittedOnDate);
result.put("daysInYear", 365);
}
BigDecimal totalInterest = maturityAmount.subtract(totalPrincipal);
String warning = terms != null ? "Calculated with the product's interest settings, assuming every installment is paid on its due date"
: "This is an approximate calculated amount - it may vary slightly when the account is created";
String warning = "This is an approximate calculated amount - it may vary slightly when the account is created";
HashMap<String, Object> result = new HashMap<>();
result.put("maturityAmount", maturityAmount);
result.put("totalPrincipal", totalPrincipal);
result.put("totalInterest", totalInterest);
result.put("daysInYear", 365); // Standard for this product as per image
result.put("warning", warning);
return result;
}
private ProductInterestTerms productInterestTerms(final Long productId) {
try {
return this.jdbcTemplate.queryForObject("""
SELECT interest_compounding_period_enum, interest_posting_period_enum, interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum, interest_calculate_selector,
min_balance_for_interest_calculation, currency_code, currency_digits, currency_multiplesof
FROM m_savings_product WHERE id = ? AND deposit_type_enum = 300
""", (rs, rowNum) -> {
final Integer selector = (Integer) rs.getObject("interest_calculate_selector");
final Integer multiplesOf = (Integer) rs.getObject("currency_multiplesof");
return new ProductInterestTerms(SavingsCompoundingInterestPeriodType.fromInt(rs.getInt("interest_compounding_period_enum")),
SavingsPostingInterestPeriodType.fromInt(rs.getInt("interest_posting_period_enum")),
SavingsInterestCalculationType.fromInt(rs.getInt("interest_calculation_type_enum")),
SavingsInterestCalculationDaysInYearType.fromInt(rs.getInt("interest_calculation_days_in_year_type_enum")),
this.recurringDepositProductRules.isSimpleInterestOption(selector), rs.getBigDecimal("min_balance_for_interest_calculation"),
new MonetaryCurrency(rs.getString("currency_code"), rs.getInt("currency_digits"), multiplesOf));
}, productId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
/**
* Interest on a deposit of {@code installment} every month from {@code startDate} for {@code months} months, worked
* out by the account engine itself (posting periods, compounding periods, daily balances, days in year, simple or
* compounding) up to the day before maturity - the same calculation that gives an opened account its maturity
* amount. Each posting period's credit is added to {@code postings}.
*/
private BigDecimal projectInterest(final ProductInterestTerms terms, final BigDecimal installment, final BigDecimal annualRate,
final int months, final LocalDate startDate, final List<Map<String, Object>> postings) {
final LocalDate termEnd = startDate.plusMonths(months).minusDays(1);
final MonetaryCurrency currency = terms.currency();
final List<SavingsAccountTransactionDetailsForPostingPeriod> deposits = new ArrayList<>();
for (int k = 0; k < months; k++) {
final LocalDate dueDate = startDate.plusMonths(k);
final LocalDate balanceEnd = k == months - 1 ? termEnd : startDate.plusMonths(k + 1L).minusDays(1);
final BigDecimal runningBalance = installment.multiply(BigDecimal.valueOf(k + 1L));
final int days = LocalDateInterval.create(dueDate, balanceEnd).daysInPeriodInclusiveOfEndDate();
deposits.add(new SavingsAccountTransactionDetailsForPostingPeriod(null, dueDate, balanceEnd, runningBalance, installment, currency,
days, true, false, false, false, false));
}
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean postingAtPeriodEnd = this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd();
final List<LocalDateInterval> intervals = this.savingsHelper.determineInterestPostingPeriods(startDate, termEnd, terms.posting(),
financialYearBeginningMonth, new ArrayList<>());
final BigDecimal rateAsFraction = annualRate.divide(BigDecimal.valueOf(100L), MathContext.DECIMAL64);
final Money minBalance = Money.of(currency, terms.minBalanceForInterest() == null ? BigDecimal.ZERO : terms.minBalanceForInterest());
final List<PostingPeriod> periods = new ArrayList<>();
Money periodStartingBalance = Money.zero(currency);
for (final LocalDateInterval interval : intervals) {
final PostingPeriod period = PostingPeriod.createFrom(interval, periodStartingBalance, deposits, currency, terms.compounding(),
terms.calculationType(), rateAsFraction, terms.daysInYear().getValue(), termEnd, new ArrayList<>(), false, minBalance,
postingAtPeriodEnd, false, financialYearBeginningMonth);
periodStartingBalance = period.closingBalance();
periods.add(period);
}
this.savingsHelper.calculateInterestForAllPostingPeriods(currency, periods, null, false, terms.simpleInterest());
BigDecimal total = BigDecimal.ZERO;
for (final PostingPeriod period : periods) {
final BigDecimal interest = period.getInterestEarned().getAmount();
total = total.add(interest);
final Map<String, Object> posting = new LinkedHashMap<>();
// The last period is credited on the maturity date itself.
final LocalDate periodEnd = period.getPeriodInterval().endDate();
final LocalDate postedOn = periodEnd.isBefore(termEnd) ? periodEnd : termEnd.plusDays(1);
posting.put("date", postedOn.toString());
posting.put("interest", interest);
postings.add(posting);
}
return total;
}
public BigDecimal calculateInterestInternal(BigDecimal installmentAmount, BigDecimal annualInterestRate, Long tenureInMonths,
Long interestCompoundingPeriodInMonths, LocalDate submittedOnDate) {
MathContext mc = MoneyHelper.getMathContext();
@@ -210,31 +89,39 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
for (int m = 1; m <= totalMonths; m++) {
// Add monthly installment at the start of the month
currentBalance = currentBalance.add(installmentAmount);
// Get actual days in the current month
int daysInMonth = date.lengthOfMonth();
// Calculate interest for the month using daily compounding
double multiplier = Math.pow(1.0 + dailyRate, daysInMonth);
currentBalance = currentBalance.multiply(BigDecimal.valueOf(multiplier), mc);
// Move to next month
date = date.plusMonths(1);
}
maturityAmount = currentBalance;
} else {
// Periodic Compounding (Monthly, Quarterly, etc.)
BigDecimal monthlyRate = annualInterestRate.divide(BigDecimal.valueOf(1200), mc);
BigDecimal i = monthlyRate;
int n = totalMonths;
// Standard RD formula for monthly compounding:
// M = P * ((1+i)^n - 1) / i * (1+i)
// For other periods, we can approximate or use a loop.
// Using loop for consistency with various compounding periods.
BigDecimal compoundingFrequencyMonths = BigDecimal.valueOf(interestCompoundingPeriodInMonths);
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc).multiply(compoundingFrequencyMonths)
.divide(BigDecimal.valueOf(12), mc);
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc)
.multiply(compoundingFrequencyMonths).divide(BigDecimal.valueOf(12), mc);
for (int m = 1; m <= totalMonths; m++) {
BigDecimal monthsRemaining = BigDecimal.valueOf(totalMonths - m + 1);
BigDecimal numberOfCompoundings = monthsRemaining.divide(compoundingFrequencyMonths, mc);
BigDecimal installmentMaturity = installmentAmount
.multiply(BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
BigDecimal installmentMaturity = installmentAmount.multiply(
BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
// Add remaining partial compounding period interest (simple interest for the rest)
BigDecimal remainingMonths = monthsRemaining.remainder(compoundingFrequencyMonths);
if (remainingMonths.compareTo(BigDecimal.ZERO) > 0) {
@@ -242,11 +129,11 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
.divide(BigDecimal.valueOf(1200), mc);
installmentMaturity = installmentMaturity.add(partialInterest);
}
maturityAmount = maturityAmount.add(installmentMaturity);
}
}
return maturityAmount.setScale(2, RoundingMode.HALF_UP);
}
}
@@ -1,157 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.account.PortfolioAccountType;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* RD Auto Debit: on each due date, collects the installment of every recurring deposit opened with Auto Debit from
* the Savings account linked to it, as an ordinary account transfer (debit the savings account, credit the deposit),
* so it is booked, journalled and shown on both statements exactly like a transfer the customer made themselves.
*
* <ul>
* <li>Only installments due on or before the business date and not yet fully paid are collected - oldest first, one
* transfer each, each in its own transaction.</li>
* <li>An installment the savings account cannot cover is skipped and stays overdue; nothing is partly collected, and
* the rest of that deposit's installments wait for the next run so they are never paid out of order. Every other
* deposit is unaffected.</li>
* <li>Retrying is the product's choice ("Allow Debit Txn after due date"): with it on, anything still unpaid is
* collected on a later run; with it off, an installment is debited only on its due date and a missed one stays
* overdue for the customer to pay (and for the missed-installment penalty, if the product has one).</li>
* </ul>
*/
@Slf4j
@Service
public class RecurringDepositAutoDebitService {
/**
* Unpaid installments, plus per deposit what has been due up to today and what has actually been paid in. The two
* totals matter because a deposit opened without "adjust advance payments" never allocates money paid before a
* due date to that installment - the schedule shows it unpaid though the customer already paid at the counter.
*/
private static final String DUE_INSTALLMENTS_SQL = """
SELECT rd.id AS rd_id, rd.linked_account_id AS savings_id, s.duedate AS due_date,
s.deposit_amount - COALESCE(s.deposit_amount_completed_derived, 0) AS outstanding,
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
WHERE s2.savings_account_id = rd.id AND s2.duedate <= ?) AS total_due,
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
FROM m_savings_account rd
JOIN m_savings_product sp ON sp.id = rd.product_id
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
JOIN m_savings_account sav ON sav.id = rd.linked_account_id
WHERE rd.deposit_type_enum = 300
AND rd.status_enum = 300
AND rd.auto_debit_facility = true
AND UPPER(rd.linked_account_type) = 'SAVINGS'
AND sav.deposit_type_enum = 100
AND sav.status_enum = 300
AND COALESCE(s.completed_derived, false) = false
AND s.duedate <= ?
AND (COALESCE(sp.allow_debit_txn_after_due_date, false) = true OR s.duedate = ?)
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
ORDER BY rd.id, s.duedate
""";
private final JdbcTemplate jdbcTemplate;
private final AccountTransfersWritePlatformService accountTransfersWritePlatformService;
private final TransactionTemplate perInstallmentTransaction;
public RecurringDepositAutoDebitService(final JdbcTemplate jdbcTemplate,
final AccountTransfersWritePlatformService accountTransfersWritePlatformService,
final PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.accountTransfersWritePlatformService = accountTransfersWritePlatformService;
this.perInstallmentTransaction = new TransactionTemplate(transactionManager);
this.perInstallmentTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
/** @return how many installments were collected */
public int collectDueInstallments() {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final List<DueInstallment> dueInstallments = this.jdbcTemplate.query(DUE_INSTALLMENTS_SQL,
(rs, rowNum) -> new DueInstallment(rs.getLong("rd_id"), rs.getLong("savings_id"), rs.getObject("due_date", LocalDate.class),
rs.getBigDecimal("outstanding"), rs.getBigDecimal("total_due").subtract(rs.getBigDecimal("total_paid"))),
businessDate, businessDate, businessDate);
int collected = 0;
Long blockedDeposit = null;
Long currentDeposit = null;
BigDecimal stillShort = BigDecimal.ZERO;
for (final DueInstallment installment : dueInstallments) {
if (installment.recurringDepositId().equals(blockedDeposit)) {
continue;
}
if (!installment.recurringDepositId().equals(currentDeposit)) {
currentDeposit = installment.recurringDepositId();
// What the customer still owes up to today - zero when they already paid it at the counter.
stillShort = installment.shortfall().max(BigDecimal.ZERO);
}
final BigDecimal amount = installment.outstanding().min(stillShort);
if (amount.signum() <= 0) {
continue;
}
try {
this.perInstallmentTransaction.executeWithoutResult(status -> collect(installment, amount, businessDate));
stillShort = stillShort.subtract(amount);
collected++;
} catch (final RuntimeException e) {
// Most often the linked savings account is short of funds. Leave this deposit's remaining
// installments for the next run rather than paying a later one ahead of the one that failed.
blockedDeposit = installment.recurringDepositId();
log.warn("RD Auto Debit could not collect the installment due {} on recurring deposit {} from savings account {}: {}",
installment.dueDate(), installment.recurringDepositId(), installment.savingsAccountId(), e.getMessage());
}
}
log.info("RD Auto Debit collected {} of {} due installment(s) on {}", collected, dueInstallments.size(), businessDate);
return collected;
}
private void collect(final DueInstallment installment, final BigDecimal amount, final LocalDate businessDate) {
final SavingsAccount fromSavingsAccount = null;
final SavingsAccount toSavingsAccount = null;
final boolean isRegularTransaction = true;
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO transfer = new AccountTransferDTO(businessDate, amount, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, installment.savingsAccountId(), installment.recurringDepositId(),
"RD Auto Debit - installment due " + installment.dueDate(), null, null, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, fromSavingsAccount,
isRegularTransaction, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(transfer);
}
/** shortfall = everything due on the deposit up to today, less everything actually paid into it. */
private record DueInstallment(Long recurringDepositId, Long savingsAccountId, LocalDate dueDate, BigDecimal outstanding,
BigDecimal shortfall) {}
}
@@ -1,176 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Lien on a recurring deposit pledged as security for a loan.
*
* <p>
* The lien itself is the existing guarantor mechanism: adding the deposit as a guarantor account on a loan records a
* funding line, and the guarantor module places the amount on hold when the loan is approved/disbursed, releases it as
* the loan is repaid, and releases the rest when the loan closes. What this class adds is what that mechanism never
* did for a recurring deposit:
* <ul>
* <li>only an RD whose product has <b>Lien Marked</b> on may be pledged, and never for more than the product's lien
* percentage of its balance (the whole balance when none is set), less what it already secures;</li>
* <li>a pledged RD cannot be closed - premature or on maturity - while any loan it secures is still pending or
* running. Before this, the hold only surfaced as an "insufficient balance" error deep inside the closure, and a pledge
* on a loan not yet approved (no hold placed yet) did not stop the deposit being paid out at all;</li>
* <li>which loans hold how much, so the RD screens can show it.</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class RecurringDepositLienService {
/**
* Pledges still in force: the funding line is active and the loan is submitted (100), approved (200) or active
* (300). A loan that is closed, rejected or withdrawn no longer binds the deposit.
*/
private static final String ACTIVE_LIENS_SQL = """
SELECT l.id AS loan_id, l.account_no AS loan_account_no, l.loan_status_id AS loan_status,
COALESCE(gfd.amount_remaining_derived, gfd.amount) AS lien_amount
FROM m_guarantor_funding_details gfd
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
JOIN m_loan l ON l.id = paa.loan_account_id
WHERE paa.linked_savings_account_id = ?
AND gfd.status_enum = 100
AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
ORDER BY l.id
""";
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
private final JdbcTemplate jdbcTemplate;
public record Lien(Long loanId, String loanAccountNo, Integer loanStatus, BigDecimal amount) {}
/**
* The loan limit a deposit carries today: whether its product allows a lien at all, the percentage that applies
* (the deposit's own, else its product's, else 100), and what that comes to on the current balance.
*/
public record LienLimit(boolean lienAllowed, BigDecimal accountPercentage, BigDecimal productPercentage,
BigDecimal effectivePercentage, BigDecimal balance, BigDecimal maxLoanAmount) {}
private static final String LIEN_LIMIT_SQL = """
SELECT sp.lien_marked AS lien_marked, sa.lien_percentage AS account_pct, sp.lien_percentage AS product_pct,
COALESCE(sa.account_balance_derived, 0) AS balance
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""";
public LienLimit lienLimit(final Long depositAccountId) {
final List<LienLimit> rows = this.jdbcTemplate.query(LIEN_LIMIT_SQL, (rs, rowNum) -> {
final boolean lienAllowed = rs.getBoolean("lien_marked");
final BigDecimal accountPct = rs.getBigDecimal("account_pct");
final BigDecimal productPct = rs.getBigDecimal("product_pct");
final BigDecimal balance = rs.getBigDecimal("balance");
final BigDecimal effective = accountPct != null ? accountPct : productPct != null ? productPct : ONE_HUNDRED;
final BigDecimal maxLoan = lienAllowed ? balance.multiply(effective).divide(ONE_HUNDRED, 2, RoundingMode.DOWN)
: BigDecimal.ZERO;
return new LienLimit(lienAllowed, accountPct, productPct, effective, balance, maxLoan);
}, depositAccountId);
return rows.isEmpty() ? null : rows.get(0);
}
public List<Lien> activeLiens(final Long depositAccountId) {
return this.jdbcTemplate.query(ACTIVE_LIENS_SQL, (rs, rowNum) -> new Lien(rs.getLong("loan_id"), rs.getString("loan_account_no"),
rs.getInt("loan_status"), rs.getBigDecimal("lien_amount")), depositAccountId);
}
public BigDecimal totalLien(final Long depositAccountId) {
return activeLiens(depositAccountId).stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
}
/** Refuses to pay out a recurring deposit that still secures a loan. */
public void assertNoActiveLien(final Long depositAccountId, final String action) {
final List<Lien> liens = activeLiens(depositAccountId);
if (liens.isEmpty()) {
return;
}
final BigDecimal total = liens.stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
final String loans = liens.stream().map(lien -> lien.loanAccountNo() + " (" + money(lien.amount()) + ")")
.collect(Collectors.joining(", "));
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.marked",
"This recurring deposit has a lien of " + money(total) + " for loan(s) " + loans + ". It cannot be " + action
+ " until the loan is repaid or the guarantee is released.",
depositAccountId, total);
}
/**
* Rules for pledging an account as loan security. Only recurring deposits are checked here; other account types
* keep the guarantor module's existing behaviour.
*
* @param excludingLoanId
* the loan being edited, whose own earlier pledge must not count against the new amount
*/
public void validatePledge(final SavingsAccount account, final BigDecimal amount, final Long excludingLoanId) {
if (account == null || !account.depositAccountType().isRecurringDeposit()) {
return;
}
if (account.savingsProduct() == null || !account.savingsProduct().isLienMarked()) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.not.allowed",
"Recurring deposit " + account.getAccountNumber() + " cannot be pledged: its product does not have Lien Marked enabled.",
account.getId());
}
if (!account.isActive()) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.account.not.active",
"Recurring deposit " + account.getAccountNumber() + " is not active, so it cannot be pledged.", account.getId());
}
final BigDecimal alreadyPledged = activeLiens(account.getId()).stream()
.filter(lien -> excludingLoanId == null || !excludingLoanId.equals(lien.loanId())).map(Lien::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// The lien percentage caps the total lien at that share of the balance as it stands now, when the loan is
// taken (90% of 1,00,000 = 90,000 across every loan the deposit secures). The deposit's own percentage, set
// when it was opened, wins; else the product's; neither means the whole balance.
final BigDecimal lienPercentage = account.getLienPercentage() != null ? account.getLienPercentage()
: account.savingsProduct().getLienPercentage();
final BigDecimal balance = account.getAccountBalance();
final BigDecimal pledgeable = lienPercentage == null ? balance
: balance.multiply(lienPercentage).divide(ONE_HUNDRED, 2, RoundingMode.DOWN);
final BigDecimal available = pledgeable.subtract(alreadyPledged);
if (amount != null && amount.compareTo(available) > 0) {
final String limitText = lienPercentage == null ? "balance " + balance.toPlainString()
: lienPercentage.stripTrailingZeros().toPlainString() + "% of balance " + balance.toPlainString() + " = "
+ pledgeable.toPlainString();
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.exceeds.limit",
"Recurring deposit " + account.getAccountNumber() + " can secure at most " + available.max(BigDecimal.ZERO).toPlainString()
+ " (" + limitText + ", less " + money(alreadyPledged) + " already pledged); "
+ money(amount) + " was requested.",
account.getId(), amount, available);
}
}
/** Rupees to the paisa for messages - "10000.00", not the stored "10000.000000". */
private static String money(final BigDecimal amount) {
return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
}
@@ -1,242 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Missed-installment penalty for recurring deposits (product setting "Penalty / Grace Period for Missed
* Installments").
*
* <p>
* Every day, each installment still unpaid when its due date plus the product's grace days has passed is charged
* the product's penalty once - a flat amount, or a percentage of the installment. The penalty is the system charge
* "RD Missed Installment Penalty" added to the deposit and paid out of its balance, so it shows on the statement as
* a charge and is booked to the product's Income From Penalties account like any other penalty.
*
* <ul>
* <li>One penalty per installment, ever: m_rd_installment_penalty records which installments have been charged,
* and an installment paid late afterwards is not charged again.</li>
* <li>Paying an installment within the grace days costs nothing.</li>
* <li>When the deposit does not yet hold enough to pay the penalty (the first installment missed, say), the charge
* stays on the account as due and every later run tries again.</li>
* </ul>
*/
@Slf4j
@Service
public class RecurringDepositMissedInstallmentPenaltyService {
public static final String PENALTY_CHARGE_NAME = "RD Missed Installment Penalty";
public static final int PENALTY_TYPE_FLAT = 1;
public static final int PENALTY_TYPE_PERCENT = 2;
private static final String DATE_FORMAT = "yyyy-MM-dd";
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
/** Unpaid installments of active RDs whose product has the penalty on and that have not been penalised yet. */
private static final String CANDIDATES_SQL = """
SELECT s.id AS installment_id, rd.id AS rd_id, s.duedate AS due_date, s.deposit_amount AS installment_amount,
COALESCE(sp.missed_installment_grace_days, 0) AS grace_days,
COALESCE(sp.missed_installment_penalty_type, 1) AS penalty_type,
sp.missed_installment_penalty_amount AS penalty_amount,
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
WHERE s2.savings_account_id = rd.id AND s2.duedate <= s.duedate) AS due_through_installment,
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
FROM m_savings_account rd
JOIN m_savings_product sp ON sp.id = rd.product_id
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
WHERE rd.deposit_type_enum = 300
AND rd.status_enum = 300
AND sp.penalty_grace_period_missed_installments = true
AND COALESCE(sp.missed_installment_penalty_amount, 0) > 0
AND COALESCE(s.completed_derived, false) = false
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
AND s.duedate < ?
AND NOT EXISTS (SELECT 1 FROM m_rd_installment_penalty p WHERE p.installment_id = s.id)
ORDER BY rd.id, s.duedate
""";
/**
* Penalty charges still unpaid on active recurring deposits. Found by the penalty charge definition rather than
* through the tracking table, so nothing depends on the charge id being known at the moment it was added.
*/
private static final String UNPAID_SQL = """
SELECT sac.savings_account_id AS rd_id, sac.id AS charge_id, sac.amount_outstanding_derived AS outstanding
FROM m_savings_account_charge sac
JOIN m_savings_account rd ON rd.id = sac.savings_account_id
WHERE sac.charge_id = ?
AND rd.deposit_type_enum = 300
AND rd.status_enum = 300
AND sac.is_active = true
AND COALESCE(sac.is_paid_derived, false) = false
AND COALESCE(sac.waived, false) = false
AND sac.amount_outstanding_derived > 0
ORDER BY sac.id
""";
private final JdbcTemplate jdbcTemplate;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final FromJsonHelper fromJsonHelper;
private final TransactionTemplate ownTransaction;
public RecurringDepositMissedInstallmentPenaltyService(final JdbcTemplate jdbcTemplate,
final DepositAccountWritePlatformService depositAccountWritePlatformService, final FromJsonHelper fromJsonHelper,
final PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
this.fromJsonHelper = fromJsonHelper;
this.ownTransaction = new TransactionTemplate(transactionManager);
this.ownTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
/**
* paidInFull: everything due up to and including this installment has actually been paid into the deposit - e.g.
* in advance at the counter, which a deposit opened without "adjust advance payments" never allocates to the
* installment itself. Such an installment only looks unpaid and must not be penalised.
*/
private record Candidate(Long installmentId, Long rdId, LocalDate dueDate, BigDecimal installmentAmount, int graceDays, int penaltyType,
BigDecimal penaltyAmount, boolean paidInFull) {}
private record Unpaid(Long rdId, Long chargeId, BigDecimal outstanding) {}
/** @return how many installments were newly penalised */
public int applyPenalties() {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final Long penaltyChargeId = penaltyChargeId();
if (penaltyChargeId == null) {
log.warn("Charge '{}' not found - missed-installment penalties cannot be applied", PENALTY_CHARGE_NAME);
return 0;
}
final List<Candidate> candidates = this.jdbcTemplate.query(CANDIDATES_SQL,
(rs, rowNum) -> new Candidate(rs.getLong("installment_id"), rs.getLong("rd_id"), rs.getObject("due_date", LocalDate.class),
rs.getBigDecimal("installment_amount"), rs.getInt("grace_days"), rs.getInt("penalty_type"),
rs.getBigDecimal("penalty_amount"),
rs.getBigDecimal("total_paid").compareTo(rs.getBigDecimal("due_through_installment")) >= 0),
businessDate);
int penalised = 0;
for (final Candidate candidate : candidates) {
// Grace: due on the 10th with 5 grace days is penalised from the 16th.
if (!candidate.dueDate().plusDays(candidate.graceDays()).isBefore(businessDate) || candidate.paidInFull()) {
continue;
}
final BigDecimal penalty = penaltyFor(candidate);
if (penalty.signum() <= 0) {
continue;
}
try {
this.ownTransaction.executeWithoutResult(status -> charge(candidate, penalty, penaltyChargeId, businessDate));
penalised++;
} catch (final RuntimeException e) {
log.warn("Could not charge the missed-installment penalty on RD {} (installment due {}): {}", candidate.rdId(),
candidate.dueDate(), e.getMessage());
}
}
// Collect everything now due - tonight's penalties and any an earlier run could not cover.
collectUnpaid(penaltyChargeId, businessDate);
log.info("RD missed-installment penalty: {} installment(s) penalised on {}", penalised, businessDate);
return penalised;
}
/** Flat amount, or the percentage of this installment, rounded to the paisa. */
static BigDecimal penaltyFor(final Candidate candidate) {
if (candidate.penaltyType() == PENALTY_TYPE_PERCENT) {
return candidate.installmentAmount().multiply(candidate.penaltyAmount()).divide(ONE_HUNDRED, 2, RoundingMode.HALF_UP);
}
return candidate.penaltyAmount().setScale(2, RoundingMode.HALF_UP);
}
/** Records the installment as penalised and adds the penalty charge to the deposit, as one unit. */
private void charge(final Candidate candidate, final BigDecimal penalty, final Long penaltyChargeId, final LocalDate businessDate) {
this.jdbcTemplate.update("""
INSERT INTO m_rd_installment_penalty (installment_id, savings_account_id, penalty_amount, applied_on_date)
VALUES (?, ?, ?, ?)
""", candidate.installmentId(), candidate.rdId(), penalty, businessDate);
final JsonObject body = new JsonObject();
body.addProperty("chargeId", penaltyChargeId);
body.addProperty("amount", penalty);
body.addProperty("dueDate", businessDate.toString());
body.addProperty("dateFormat", DATE_FORMAT);
body.addProperty("locale", "en");
final CommandProcessingResult result = this.depositAccountWritePlatformService.addSavingsAccountCharge(command(body, candidate.rdId()),
DepositAccountType.RECURRING_DEPOSIT);
// The charge's id is only known here if the add happened to flush; keep the link when it is, for reference.
if (result.getResourceId() != null) {
this.jdbcTemplate.update("UPDATE m_rd_installment_penalty SET savings_account_charge_id = ? WHERE installment_id = ?",
result.getResourceId(), candidate.installmentId());
}
}
private void collectUnpaid(final Long penaltyChargeId, final LocalDate businessDate) {
final List<Unpaid> unpaid = this.jdbcTemplate.query(UNPAID_SQL,
(rs, rowNum) -> new Unpaid(rs.getLong("rd_id"), rs.getLong("charge_id"), rs.getBigDecimal("outstanding")), penaltyChargeId);
for (final Unpaid charge : unpaid) {
tryPay(charge.rdId(), charge.chargeId(), charge.outstanding(), businessDate);
}
}
/** Pays the penalty from the deposit's balance; if it cannot be covered yet the charge simply stays due. */
private void tryPay(final Long rdId, final Long chargeId, final BigDecimal amount, final LocalDate businessDate) {
final JsonObject body = new JsonObject();
body.addProperty("amount", amount);
body.addProperty("dueDate", businessDate.toString());
body.addProperty("dateFormat", DATE_FORMAT);
body.addProperty("locale", "en");
try {
this.ownTransaction.executeWithoutResult(status -> this.depositAccountWritePlatformService.payCharge(rdId, chargeId,
command(body, rdId), DepositAccountType.RECURRING_DEPOSIT));
} catch (final RuntimeException e) {
log.info("Missed-installment penalty {} on RD {} left due (not collected yet): {}", chargeId, rdId, e.getMessage());
}
}
private Long penaltyChargeId() {
try {
return this.jdbcTemplate.queryForObject(
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false AND is_active = true ORDER BY id LIMIT 1", Long.class,
PENALTY_CHARGE_NAME);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
/** A command carrying the deposit's id the way the charge endpoints receive it from the API. */
private JsonCommand command(final JsonObject body, final Long rdId) {
return new JsonCommand(null, body.toString(), body, this.fromJsonHelper, null, rdId, null, null, null, null, rdId, null, null,
null, null, null, null, null);
}
}
@@ -1,301 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.savings.domain.DepositProductAmountDetails;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProduct;
import org.apache.fineract.portfolio.savings.domain.SavingsProduct;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The recurring-deposit product settings that only mean something when an account is opened against them. Each one
* used to be stored on the product and then ignored; they are enforced here, on the server, so a client that skips
* the screen's own checks cannot open an account the product does not allow.
*
* <ul>
* <li>Effective Date - the first day the product can be used. No account may be opened before it.</li>
* <li>Effective To - the last day the product is on sale. No new account may be opened after it; accounts already
* open run to maturity as normal.</li>
* <li>End Date - the day the product is wound up. No account may be opened that would still be running after it, so
* every account opened under the product has matured by then.</li>
* <li>Deposit Amount Min / Max - bounds on the installment the customer commits to each period.</li>
* <li>Nomination Facility - when on, at least one nominee is required; when off, nominees cannot be recorded.</li>
* <li>Auto Debit Facility - only offered when the product allows it, and only with a linked Savings account to debit,
* since that is where the installment is collected from.</li>
* <li>Interest Calculation Type - copied onto the account as its simple/compounding flag (see
* {@link #isSimpleInterestOption}).</li>
* </ul>
*/
@Component
@RequiredArgsConstructor
public class RecurringDepositProductRules {
/** Label of the InterestCalculationTypeOption code value that switches a recurring deposit to simple interest. */
public static final String SIMPLE_INTEREST_OPTION_LABEL = "Simple";
private static final String LINKED_ACCOUNT_TYPE_SAVINGS = "SAVINGS";
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
private final CodeValueRepository codeValueRepository;
private final JdbcTemplate jdbcTemplate;
/**
* Fixes the account's simple/compounding flag from its product. Must run before the maturity amount is projected,
* which is calculated with it.
*/
public void applyInterestCalculationType(final RecurringDepositAccount account) {
account.setSimpleInterest(isSimpleInterestOption(account.savingsProduct().getInterestCalculateSelector()));
}
/** Rules for a brand-new application. Runs after the maturity date is known, which End Date is checked against. */
public void validateOnSubmit(final RecurringDepositAccount account, final JsonCommand command) {
final SavingsProduct product = account.savingsProduct();
applyProductPreClosurePenalty(account, product);
applyLienPercentage(account, product, command, true);
validateOpeningWindow(account, product);
validateInstallmentAmount(account, product);
validateNomination(product, command);
validateAutoDebit(account, product);
validateIntroducer(account, product);
}
/**
* Rules for an application edited before approval. Nominees are only checked when the edit sends them, since an
* edit that leaves them out keeps the ones already recorded.
*/
public void validateOnModify(final RecurringDepositAccount account, final JsonCommand command) {
final SavingsProduct product = account.savingsProduct();
applyProductPreClosurePenalty(account, product);
applyLienPercentage(account, product, command, false);
validateOpeningWindow(account, product);
validateInstallmentAmount(account, product);
if (hasNomineesProperty(command)) {
validateNomination(product, command);
}
validateAutoDebit(account, product);
validateIntroducer(account, product);
}
/**
* Whether a product's Interest Calculation Type is "Simple". The option is a tenant-editable code value, so it is
* matched by label rather than by a hard-coded id; anything else - no selection, "Compounding", an unknown id - is
* compounding, which is how every recurring deposit was calculated before the option was honoured.
*/
public boolean isSimpleInterestOption(final Integer interestCalculateSelector) {
if (interestCalculateSelector == null) {
return false;
}
return this.codeValueRepository.findById(interestCalculateSelector.longValue())
.map(codeValue -> SIMPLE_INTEREST_OPTION_LABEL.equalsIgnoreCase(StringUtils.trim(codeValue.getLabel()))).orElse(false);
}
/**
* How much of this deposit may be given as loan. Only a Lien Marked product allows a lien at all; the account's
* percentage may be lowered from the product's but never raised above it. When the application sends none, a new
* account takes the product's percentage and an edited one keeps what it had.
*/
private void applyLienPercentage(final RecurringDepositAccount account, final SavingsProduct product, final JsonCommand command,
final boolean isSubmit) {
if (!product.isLienMarked()) {
account.setLienPercentage(null);
return;
}
final BigDecimal productLimit = product.getLienPercentage() == null ? ONE_HUNDRED : product.getLienPercentage();
if (!command.parameterExists("lienPercentage") || command.bigDecimalValueOfParameterNamed("lienPercentage") == null) {
if (isSubmit) {
account.setLienPercentage(product.getLienPercentage());
}
return;
}
final BigDecimal requested = command.bigDecimalValueOfParameterNamed("lienPercentage");
if (requested.signum() <= 0 || requested.compareTo(productLimit) > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.percentage.out.of.range",
"Loan against this deposit must be more than 0% and at most " + productLimit.stripTrailingZeros().toPlainString()
+ "% (the limit of product '" + product.getName() + "'); " + requested.stripTrailingZeros().toPlainString() + "% was entered.",
requested, productLimit);
}
account.setLienPercentage(requested);
}
private void validateOpeningWindow(final RecurringDepositAccount account, final SavingsProduct product) {
final LocalDate openedOn = account.getSubmittedOnDate();
if (openedOn == null) {
return;
}
final LocalDate effectiveFrom = product.getEffectiveDate();
if (effectiveFrom != null && openedOn.isBefore(effectiveFrom)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.not.yet.effective",
"Product '" + product.getName() + "' can only be used from " + effectiveFrom + "; this application is dated "
+ openedOn + ".",
effectiveFrom, openedOn);
}
final LocalDate effectiveTo = product.getEffectiveTo();
if (effectiveTo != null && openedOn.isAfter(effectiveTo)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.closed.for.new.accounts",
"Product '" + product.getName() + "' stopped accepting new accounts after " + effectiveTo
+ "; this application is dated " + openedOn + ".",
effectiveTo, openedOn);
}
final LocalDate endDate = product.getEndDate();
final LocalDate maturityDate = account.maturityDate();
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
+ ". Choose a shorter tenure.",
maturityDate, endDate);
}
}
/**
* "Apply Penal Interest (less)" is the product's decision, not the account's: whatever the application sent, the
* deposit takes the product's penalty - on or off, the same percentage and the same period (Whole Term / Till
* Premature Withdrawal). A product without the penalty means no penal interest on premature closure.
*/
private void applyProductPreClosurePenalty(final RecurringDepositAccount account, final SavingsProduct product) {
if (product instanceof RecurringDepositProduct rdProduct && rdProduct.depositProductTermAndPreClosure() != null) {
account.applyProductPreClosurePenalty(rdProduct.depositProductTermAndPreClosure().depositPreClosureDetail());
}
}
/** A deposit whose tenure is extended must still mature on or before its product's End Date. */
public void validateMaturityWithinProductEndDate(final RecurringDepositAccount account) {
final SavingsProduct product = account.savingsProduct();
final LocalDate endDate = product.getEndDate();
final LocalDate maturityDate = account.maturityDate();
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
+ ". Choose a shorter tenure.",
maturityDate, endDate);
}
}
private void validateInstallmentAmount(final RecurringDepositAccount account, final SavingsProduct product) {
if (!(product instanceof RecurringDepositProduct rdProduct) || rdProduct.depositProductTermAndPreClosure() == null) {
return;
}
final DepositProductAmountDetails limits = rdProduct.depositProductTermAndPreClosure().depositProductAmountDetails();
final BigDecimal installment = account.getRecurringDetail() == null ? null
: account.getRecurringDetail().mandatoryRecommendedDepositAmount();
if (limits == null || installment == null) {
return;
}
final BigDecimal min = limits.getMinDepositAmount();
if (min != null && min.signum() > 0 && installment.compareTo(min) < 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.below.product.minimum",
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is below the product minimum of " + min.stripTrailingZeros().toPlainString()
+ ".",
installment, min);
}
final BigDecimal max = limits.getMaxDepositAmount();
if (max != null && max.signum() > 0 && installment.compareTo(max) > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.above.product.maximum",
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is above the product maximum of " + max.stripTrailingZeros().toPlainString()
+ ".",
installment, max);
}
}
private void validateNomination(final SavingsProduct product, final JsonCommand command) {
final boolean hasNominees = hasNominees(command);
if (product.isNominationFacility() && !hasNominees) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nominee.required",
"Product '" + product.getName() + "' has Nomination Facility enabled - add at least one nominee.");
}
if (!product.isNominationFacility() && hasNominees) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nomination.not.offered",
"Product '" + product.getName() + "' does not offer a Nomination Facility, so nominees cannot be recorded.");
}
}
/**
* "Introducer Required" on the product: the deposit must name an introducer - an existing, active customer of the
* bank, and not the depositor themselves.
*/
private void validateIntroducer(final RecurringDepositAccount account, final SavingsProduct product) {
if (!product.isIntroducerRequired()) {
return;
}
final Long introducerId = account.getIntroducerClientId();
if (introducerId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.required",
"Product '" + product.getName() + "' requires an introducer - choose an existing customer as introducer.");
}
if (introducerId.equals(account.clientId())) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.self",
"The depositor cannot be their own introducer.");
}
final Integer active = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_client WHERE id = ? AND status_enum = 300",
Integer.class, introducerId);
if (active == null || active == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.not.active",
"The introducer must be an active customer of the bank.");
}
}
private void validateAutoDebit(final RecurringDepositAccount account, final SavingsProduct product) {
if (!Boolean.TRUE.equals(account.getAutoDebitFacility())) {
return;
}
if (!product.isAutoDebitFacility()) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.not.offered",
"Product '" + product.getName() + "' does not offer Auto Debit.");
}
final Long linkedAccountId = account.getLinkedAccountId();
if (linkedAccountId == null || !LINKED_ACCOUNT_TYPE_SAVINGS.equalsIgnoreCase(account.getLinkedAccountType())) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.required",
"Auto Debit collects each installment from the linked account, so link an active Savings account to use it.");
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND client_id = ? AND deposit_type_enum = 100 AND status_enum = 300",
Integer.class, linkedAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.not.active",
"Linked account " + linkedAccountId + " is not an active Savings account of this client, so Auto Debit cannot use it.",
linkedAccountId);
}
}
private static boolean hasNomineesProperty(final JsonCommand command) {
final JsonObject root = command.parsedJson().getAsJsonObject();
return root.has("nominees") || root.has("nominee");
}
private static boolean hasNominees(final JsonCommand command) {
final JsonObject root = command.parsedJson().getAsJsonObject();
final JsonElement nominees = root.get("nominees");
if (nominees != null && nominees.isJsonArray()) {
return !nominees.getAsJsonArray().isEmpty();
}
final JsonElement nominee = root.get("nominee");
return nominee != null && nominee.isJsonObject();
}
}
@@ -374,10 +374,8 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
// this runs per account inside a batch job, so a null deposit type must not take the whole run down.
final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount()
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
// A simple-interest recurring deposit must post here exactly what its own maturity projection promised, so the
// flag is the same one SavingsAccount#calculateInterestUsing honours.
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled, savingsAccountData.isSimpleInterest());
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled);
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
@@ -438,12 +438,10 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
// A product on the daily-interest timetable is paid by the pooled posting instead
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
// FD products are never on that timetable, so they still post here. A recurring deposit (deposit_type_enum
// 300) is left out: its posting periods are credited by the nightly deposit maturity job instead
// (RecurringDepositAccount#postPeriodicInterest), at the chart rate and on deposits alone. The filters sit inside the paging subquery so a page is never cut short by
// accounts that are then dropped.
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
// subquery so a page is never cut short by accounts that are then dropped.
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? and a.deposit_type_enum <> 300 "
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
if (backdatedTxnsAllowedTill) {
@@ -472,7 +470,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, ");
sqlBuilder.append("sa.deposit_type_enum as depositType, ");
sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, ");
sqlBuilder.append("sa.is_simple_interest as isSimpleInterest, ");
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
sqlBuilder.append("sa.status_enum as statusEnum, ");
@@ -842,7 +839,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
// Monthly-payout deposits must not compound: their interest leaves the account every
// period, so each period earns on the principal alone.
savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount"));
savingsAccountData.setSimpleInterest(rs.getBoolean("isSimpleInterest"));
}
if (!transMap.containsValue(transactionId)) {
@@ -106,8 +106,6 @@ import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
import org.apache.fineract.portfolio.savings.service.RecurringDepositProductRules;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.DepositAccountOnHoldTransactionReadPlatformService;
@@ -299,8 +297,7 @@ public class SavingsConfiguration {
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
WorkingDaysRepositoryWrapper workingDaysRepository,
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate,
RecurringDepositLienService recurringDepositLienService, RecurringDepositProductRules recurringDepositProductRules
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
) {
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
@@ -310,7 +307,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
denominationBreakdownWriter, jdbcTemplate, recurringDepositLienService, recurringDepositProductRules);
denominationBreakdownWriter, jdbcTemplate);
}
@Bean
@@ -326,14 +323,13 @@ public class SavingsConfiguration {
FromJsonHelper fromJsonHelper, CalendarInstanceRepository calendarInstanceRepository,
ConfigurationDomainService configurationDomainService, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
BusinessEventNotifierService businessEventNotifierService, SavingsAccountAssembler savingsAccountAssembler,
ClientNomineeWritePlatformService clientNomineeWritePlatformService,
RecurringDepositProductRules recurringDepositProductRules) {
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
return new DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository,
fixedDepositAccountRepository, recurringDepositAccountRepository, depositAccountAssembler, depositAccountDataValidator,
accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository, noteRepository, staffRepository,
savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler, accountAssociationsRepository,
fromJsonHelper, calendarInstanceRepository, configurationDomainService, accountNumberFormatRepository,
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService, recurringDepositProductRules);
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService);
}
@Bean
@@ -281,9 +281,7 @@ 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 "
// 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 "
+ "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 "
@@ -312,7 +310,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.getObject("dd_payable_gl_account_id") != null ? rs.getLong("dd_payable_gl_account_id") : null;
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
final String ddPayableGlCode = rs.getString("dd_payable_gl_code");
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
@@ -52,10 +52,10 @@ public class TdsInterestApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Deposit interest per customer for a financial year",
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
public String retrieve(@QueryParam("financialYear") final Integer financialYear, @QueryParam("clientId") final Long clientId) {
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
this.context.authenticatedUser();
final List<TdsDeducteeData> deductees = this.readPlatformService
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId);
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
return this.toApiJsonSerializer.serialize(deductees);
}
@@ -1,490 +0,0 @@
/**
* 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.");
}
}
}
@@ -19,13 +19,12 @@
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, together with the
* accounts and the interest actually credited to them.
* 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.
*
* <p>
* The section bites on time deposits, so savings accounts are carried here as well but marked by
@@ -33,31 +32,23 @@ 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, List<TdsDeclarationData> declarations, List<TdsAccountData> accounts) implements Serializable {
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
/**
* One deposit account, the interest posted to it and the TDS actually deducted from it.
* One deposit account and the interest posted to 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, Long clientId, Long holderClientId,
String holderName, String accountStatus, LocalDate openedOn, List<TdsInterestCreditData> credits,
List<TdsInterestCreditData> deductions) implements Serializable {}
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) 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 {}
/** One interest posting: the date it was credited and the amount. */
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
implements Serializable {}
}
@@ -1,389 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.tds.service;
import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
import org.apache.fineract.portfolio.tax.domain.TaxComponent;
import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* TDS on FD/RD interest, tracked and deducted where the interest is received.
*
* <p>
* Normally that is the customer's savings account: every time money moves from a fixed or recurring
* deposit into savings, the interest in it is recorded against the deductee (see
* {@link TdsSql#deducteeOf}) and any TDS due is debited from that savings account. The periodic
* interest sweep is all interest; a maturity or closure payout is principal and interest together, so
* only the interest not already received is counted. The FD and RD are not debited.
*
* <p>
* When a maturity or closure pays out in cash, by cheque or to a GL account instead, the interest never
* reaches savings, so it is tracked at the payout and the TDS comes out of the payout itself. A full
* reinvestment pays nothing out and is not taxed here.
*
* <p>
* The rules, all for one financial year and one deductee:
* <ol>
* <li>The FD/RD interest received, across every branch, is added up. The savings account's own interest
* is not - it is outside the charge.</li>
* <li>A declaration shields the interest received from the day it is accepted. Interest received
* before it stays liable.</li>
* <li>Nothing is deducted while the total stays within the threshold (senior or general).</li>
* <li>Once over it, the whole year's interest is liable - not merely the excess - at the PAN rate, or
* the higher rate without a valid PAN.</li>
* <li>What is deducted now is that liability less everything already deducted this year, so the credit
* that crosses the threshold catches up the earlier ones, and nothing is ever deducted twice.</li>
* </ol>
*
* <p>
* The tax is posted as a withhold-tax transaction through a tax component whose credit account is the
* TDS payable account set on Organization › TDS Configuration, so the passbook shows it and the
* account's own accounting writes Dr deposit liability / Cr TDS payable.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SavingsInterestTdsService {
/** The tax component every TDS deduction is booked through. Kept in step with the GL setting. */
public static final String TDS_COMPONENT_NAME = "TDS on deposit interest";
private final JdbcTemplate jdbcTemplate;
private final TaxComponentRepository taxComponentRepository;
private final EntityManager entityManager;
private final SavingsAccountRepositoryWrapper savingsAccountRepository;
private final SavingsAccountTransactionRepository savingsAccountTransactionRepository;
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
/**
* Called once a savings-to-savings transfer has been saved.
*
* @param deposit
* the credit on the receiving savings account
*/
public void onTransfer(final SavingsAccount from, final SavingsAccount to, final SavingsAccountTransaction deposit,
final AccountTransferType transferType) {
if (from == null || to == null || deposit == null || deposit.getId() == null || !isTimeDeposit(from)) {
return;
}
final DepositAccountType toType = to.depositAccountType();
if (toType == null || !toType.isSavingsDeposit()) {
return;
}
final boolean sweep = transferType != null && transferType.isInterestTransfer();
final BigDecimal interest = sweep ? deposit.getAmount()
: interestInPayout(from.getId(), postedFromDatabase(from.getId()), deposit.getAmount());
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
final LocalDate date = deposit.getTransactionDate();
final Year year = Year.of(date);
final Long deductee = deducteeOf(from, year);
if (deductee == null) {
return;
}
final Long receiptId = insertReceipt(deductee, to.getId(), deposit.getId(), from.getId(), sweep ? "INTEREST" : "PAYOUT", year,
date, interest);
final Due due = dueFor(deductee, year, date, BigDecimal.ZERO);
if (due == null) {
return;
}
final Set<Long> existingIds = new HashSet<>(to.findExistingTransactionIds());
final Set<Long> existingReversedIds = new HashSet<>(to.findExistingReversedTransactionIds());
final SavingsAccountTransaction tds = to.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
// Saved on its own first, as Fineract does for every new transaction: saving only the account
// merges it, and the id lands on the merged copy - this instance stays null, which broke both
// the deduction row and the journal entry's transaction reference.
this.savingsAccountTransactionRepository.saveAndFlush(tds);
this.savingsAccountRepository.saveAndFlush(to);
this.journalEntryWritePlatformService.createJournalEntriesForSavings(
to.deriveAccountingBridgeData(to.getCurrency().getCode(), existingIds, existingReversedIds, false, false));
recordDeduction(deductee, to.getId(), tds.getId(), from.getId(), receiptId, year, date, due);
}
/**
* Interest paid straight out of an FD/RD at maturity or closure - in cash, by cheque, or to a GL
* account - never reaches a savings account, so it is tracked here instead, and any TDS it makes due
* is taken out of the payout itself.
*
* <p>
* Call just before the closing withdrawal is worked out: the deduction is added to the deposit, so a
* withdrawal of the account balance pays out what is left. The deposit is not saved here - the
* closing withdrawal saves it and posts the deduction's accounting along with its own. Then call
* {@link #recordDirectPayout}.
*
* @param kind
* CASH or GL, for the tracking record
* @return what to record once the withdrawal exists, or null when the payout carried no interest
*/
public DirectPayout deductOnDirectPayout(final SavingsAccount deposit, final LocalDate date, final String kind) {
if (deposit == null || deposit.getId() == null || date == null || !isTimeDeposit(deposit)) {
return null;
}
// From memory: the maturity or pre-closure interest posted moments ago is not saved yet.
BigDecimal posted = BigDecimal.ZERO;
for (final SavingsAccountTransaction t : deposit.getTransactions()) {
if (t.isInterestPostingAndNotReversed() && !t.isReversalTransaction()) {
posted = posted.add(t.getAmount());
}
}
final BigDecimal interest = interestInPayout(deposit.getId(), posted, deposit.getAccountBalance());
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
final Year year = Year.of(date);
final Long deductee = deducteeOf(deposit, year);
if (deductee == null) {
return null;
}
final Due due = dueFor(deductee, year, date, interest);
final SavingsAccountTransaction tds = due == null ? null
: deposit.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
return new DirectPayout(deposit.getId(), deductee, year, date, interest, kind, due, tds);
}
/**
* Records the tracking row and the deduction for a direct payout, once the closing withdrawal has been
* saved, and marks the deduction as already posted so the caller's own journal run does not post it a
* second time - the withdrawal posted it.
*/
public void recordDirectPayout(final DirectPayout payout, final SavingsAccountTransaction withdrawal,
final Set<Long> alreadyPostedTransactionIds) {
if (payout == null || withdrawal == null || withdrawal.getId() == null) {
return;
}
final Long receiptId = insertReceipt(payout.deductee(), payout.accountId(), withdrawal.getId(), payout.accountId(), payout.kind(),
payout.year(), payout.date(), payout.interest());
if (payout.tds() != null && payout.tds().getId() != null) {
recordDeduction(payout.deductee(), payout.accountId(), payout.tds().getId(), payout.accountId(), receiptId, payout.year(),
payout.date(), payout.due());
if (alreadyPostedTransactionIds != null) {
alreadyPostedTransactionIds.add(payout.tds().getId());
}
}
}
/** What {@link #deductOnDirectPayout} worked out, held until the withdrawal is saved. */
public record DirectPayout(Long accountId, Long deductee, Year year, LocalDate date, BigDecimal interest, String kind, Due due,
SavingsAccountTransaction tds) {}
/** TDS due now, and what it was worked out on. */
public record Due(BigDecimal amount, BigDecimal ratePercent, BigDecimal liableInterest, TaxComponent component) {}
/** One Indian financial year. */
public record Year(LocalDate start, LocalDate end, String label) {
static Year of(final LocalDate date) {
final int startYear = TdsSql.fyStartYear(date);
return new Year(LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31), TdsSql.fyLabel(startYear));
}
}
private static boolean isTimeDeposit(final SavingsAccount account) {
final DepositAccountType type = account.depositAccountType();
return type != null && (type.isFixedDeposit() || type.isRecurringDeposit());
}
private Long deducteeOf(final SavingsAccount deposit, final Year year) {
return jdbcTemplate.queryForObject("SELECT " + TdsSql.deducteeOf("sa") + " FROM m_savings_account sa WHERE sa.id = ?", Long.class,
year.start(), deposit.getId());
}
private Long insertReceipt(final Long deductee, final Long receivingAccountId, final Long txnId, final Long sourceAccountId,
final String kind, final Year year, final LocalDate date, final BigDecimal interest) {
return jdbcTemplate.queryForObject("""
INSERT INTO m_tds_interest_receipt (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id,
kind, fy_label, received_on, interest_amount, created_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, now()) RETURNING id
""", Long.class, deductee, receivingAccountId, txnId, sourceAccountId, kind, year.label(), date, interest);
}
private void recordDeduction(final Long deductee, final Long accountId, final Long txnId, final Long sourceAccountId,
final Long receiptId, final Year year, final LocalDate date, final Due due) {
jdbcTemplate.update("""
INSERT INTO m_tds_deduction (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, receipt_id,
fy_label, deducted_on, amount, rate_percentage, interest_base, created_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
""", deductee, accountId, txnId, sourceAccountId, receiptId, year.label(), date, due.amount(), due.ratePercent(),
due.liableInterest());
}
/**
* The TDS due for the deductee now, or null when nothing is.
*
* @param extraInterest
* interest received on {@code date} that is not on record yet
*/
private Due dueFor(final Long deductee, final Year year, final LocalDate date, final BigDecimal extraInterest) {
final List<Map<String, Object>> cfgRows = jdbcTemplate.queryForList("""
SELECT threshold_general, threshold_senior, rate_pan_percentage, rate_no_pan_percentage, round_to
FROM m_tds_fy_config WHERE fy_label = ?
""", year.label());
if (cfgRows.isEmpty()) {
// Tracked, but not deducted: the TDS screens show the same gap and the administrator fixes it there.
log.warn("No TDS configuration for FY {}; interest for client {} tracked but not taxed", year.label(), deductee);
return null;
}
final Map<String, Object> cfg = cfgRows.get(0);
final List<LocalDate> accepted = jdbcTemplate.queryForList("""
SELECT accepted_on FROM m_tds_declaration
WHERE client_id = ? AND fy_label = ? AND is_revoked = false
ORDER BY accepted_on
""", LocalDate.class, deductee, year.label());
final LocalDate shieldedFrom = accepted.isEmpty() ? year.end().plusDays(1) : accepted.get(0);
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(r.interest_amount), 0) AS interest,
COALESCE(SUM(CASE WHEN r.received_on < ? THEN r.interest_amount END), 0) AS liable
FROM m_tds_interest_receipt r
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
WHERE r.deductee_client_id = ? AND r.fy_label = ? AND r.received_on <= ?
""", shieldedFrom, deductee, year.label(), date);
final BigDecimal yearInterest = ((BigDecimal) totals.get("interest")).add(extraInterest);
final BigDecimal liableInterest = ((BigDecimal) totals.get("liable"))
.add(date.isBefore(shieldedFrom) ? extraInterest : BigDecimal.ZERO);
final BigDecimal deducted = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(d.amount), 0)
FROM m_tds_deduction d
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
WHERE d.deductee_client_id = ? AND d.fy_label = ?
""", BigDecimal.class, deductee, year.label());
final Map<String, Object> person = jdbcTemplate.queryForMap("""
SELECT c.date_of_birth,
(SELECT k.pan_number FROM m_client_kyc k
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
ORDER BY k.id DESC LIMIT 1) AS pan
FROM m_client c WHERE c.id = ?
""", deductee);
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), year.end());
final BigDecimal threshold = (BigDecimal) cfg.get(senior ? "threshold_senior" : "threshold_general");
if (yearInterest.compareTo(threshold) <= 0) {
return null;
}
final boolean panOk = TdsSql.isWellFormedPan((String) person.get("pan"));
final BigDecimal ratePercent = (BigDecimal) cfg.get(panOk ? "rate_pan_percentage" : "rate_no_pan_percentage");
final int roundTo = ((Number) cfg.get("round_to")).intValue();
final BigDecimal liability = TdsSql.roundToMultiple(liableInterest.multiply(ratePercent).movePointLeft(2), roundTo);
final BigDecimal already = deducted == null ? BigDecimal.ZERO : deducted;
final BigDecimal amount = liability.subtract(already);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
log.info("TDS {} due for client {} (FY {}: interest {}, liability {}, already deducted {})", amount, deductee, year.label(),
yearInterest, liability, already);
return new Due(amount, ratePercent, liableInterest, tdsComponent(ratePercent));
}
private BigDecimal postedFromDatabase(final Long accountId) {
return jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
""", BigDecimal.class, accountId, TdsSql.INTEREST_POSTING);
}
/**
* The interest inside a maturity or closure payout: interest posted on the deposit that has not been
* received already - through the interest sweep or an earlier payout. Capped at the payout.
*
* <p>
* On a premature closure the months already swept to savings stand as they were; only the interest
* for the days since is paid out now, and only that is counted.
*/
private BigDecimal interestInPayout(final Long sourceAccountId, final BigDecimal posted, final BigDecimal payout) {
final BigDecimal swept = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(att.amount), 0)
FROM m_account_transfer_transaction att
JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id
WHERE d.from_savings_account_id = ? AND d.transfer_type = ? AND att.is_reversed = false
""", BigDecimal.class, sourceAccountId, AccountTransferType.INTEREST_TRANSFER.getValue());
final BigDecimal paidOut = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(r.interest_amount), 0)
FROM m_tds_interest_receipt r
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
WHERE r.source_account_id = ? AND r.kind <> 'INTEREST'
""", BigDecimal.class, sourceAccountId);
final BigDecimal unpaid = posted.subtract(swept).subtract(paidOut);
if (unpaid.compareTo(BigDecimal.ZERO) <= 0 || payout == null) {
return BigDecimal.ZERO;
}
return unpaid.min(payout);
}
/**
* The tax component the deduction is booked through, created on first use and re-pointed whenever
* the TDS payable account on the configuration screen changes.
*/
private TaxComponent tdsComponent(final BigDecimal ratePercent) {
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
Long.class);
if (gl.isEmpty() || gl.get(0) == null) {
throw new GeneralPlatformDomainRuleException("error.msg.tds.payable.gl.not.configured",
"TDS is due on this interest but no TDS payable account is set. Choose one under Organization › TDS Configuration.");
}
final Long glId = gl.get(0);
final List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM m_tax_component WHERE name = ? ORDER BY id", Long.class,
TDS_COMPONENT_NAME);
final Long id;
boolean repointed = false;
if (existing.isEmpty()) {
id = jdbcTemplate.queryForObject("""
INSERT INTO m_tax_component (name, percentage, credit_account_type_enum, credit_account_id, start_date,
createdby_id, created_date, lastmodifiedby_id, lastmodified_date)
SELECT ?, ?, 2, ?, DATE '2000-01-01', MIN(u.id), now(), MIN(u.id), now() FROM m_appuser u
RETURNING id
""", Long.class, TDS_COMPONENT_NAME, ratePercent, glId);
} else {
id = existing.get(0);
repointed = jdbcTemplate.update("UPDATE m_tax_component SET credit_account_type_enum = 2, credit_account_id = ? WHERE id = ?"
+ " AND (credit_account_id IS DISTINCT FROM ? OR credit_account_type_enum IS DISTINCT FROM 2)", glId, id, glId) > 0;
}
final TaxComponent component = taxComponentRepository.findById(id).orElseThrow();
// Written behind JPA's back, so a copy already in this session would still credit the old account.
if (repointed) {
entityManager.refresh(component);
}
return component;
}
}
@@ -29,8 +29,6 @@ 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, Long clientId);
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
}
@@ -27,8 +27,6 @@ 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;
@@ -37,8 +35,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 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.
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
* rather than from a balance and a rate.
*/
@Service
@RequiredArgsConstructor
@@ -46,150 +44,109 @@ import org.springframework.transaction.annotation.Transactional;
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
/**
* Every account, grouped under the client whose PAN carries its deduction, with what counts for TDS
* inside the year.
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
*
* <p>
* 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.
* 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.
*
* <p>
* 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.
* 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.
*
* <p>
* 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.
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
* deduct tax on interest the customer never received.
*/
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,
(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,
k.pan_number AS pan,
k.form15_type AS form15_type,
o.name AS branch_name,
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
sa.id AS account_id,
sa.account_no AS account_no,
sa.activatedon_date AS opened_on,
CASE sa.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,
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"));
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
""";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Override
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear, final Long onlyClientId) {
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
// 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")));
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")));
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"), readLong(rs, "holder_id"),
readString(rs, "holder_name"), readString(rs, "account_status"), readDate(rs, "opened_on")));
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
// 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);
}
// 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));
}
}, f, TdsSql.INTEREST_POSTING, f, t, f, t, f, t, hierarchy, onlyClientId, onlyClientId);
final Map<Long, List<TdsDeclarationData>> declarations = declarationsFor(TdsSql.fyLabel(financialYear));
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
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()) {
accounts.add(new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, d.clientId, a.holderId, a.holderName, a.accountStatus,
a.openedOn, a.credits, a.deductions));
// 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));
}
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
declarations.getOrDefault(d.clientId, List.of()), accounts));
d.form15Type, 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);
@@ -198,15 +155,6 @@ 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);
@@ -225,15 +173,18 @@ 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) {
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
String branchName, String form15Type) {
this.clientId = clientId;
this.customerId = customerId;
this.name = name;
this.pan = pan;
this.dateOfBirth = dateOfBirth;
this.branchName = branchName;
this.form15Type = form15Type;
}
}
@@ -242,21 +193,13 @@ 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, Long holderId, String holderName, String accountStatus,
LocalDate openedOn) {
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
this.id = id;
this.accountNo = accountNo;
this.depositTypeCode = depositTypeCode;
this.holderId = holderId;
this.holderName = holderName;
this.accountStatus = accountStatus;
this.openedOn = openedOn;
}
}
@@ -1,95 +0,0 @@
/**
* 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);
}
}

Some files were not shown because too many files have changed in this diff Show More