feat(investment): bank-wise TDS on FD interest, GOI interest to the paisa and FD preview fixes
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run

This commit is contained in:
2026-10-20 16:16:06 +05:30
parent 4522f1305d
commit 9b1e381e07
14 changed files with 156 additions and 31 deletions
@@ -74,6 +74,8 @@ public final class InvestmentApiConstants {
public static final String CP_BRANCH_NAME = "cpBranchName"; public static final String CP_BRANCH_NAME = "cpBranchName";
public static final String CP_ACCOUNT_NO = "cpAccountNo"; public static final String CP_ACCOUNT_NO = "cpAccountNo";
public static final String CP_IFSC = "cpIfsc"; public static final String CP_IFSC = "cpIfsc";
public static final String TDS_APPLICABLE = "tdsApplicable";
public static final String TDS_RATE = "tdsRate";
// Script parameters // Script parameters
public static final String SLR_TYPE = "slrType"; public static final String SLR_TYPE = "slrType";
@@ -88,7 +90,7 @@ public final class InvestmentApiConstants {
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, 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, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE, BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
LOCALE); TDS_APPLICABLE, TDS_RATE, LOCALE);
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT, 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, INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
@@ -39,6 +39,7 @@ import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentFdClosureData; import org.apache.fineract.portfolio.investment.data.InvestmentFdClosureData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdData; import org.apache.fineract.portfolio.investment.data.InvestmentFdData;
@@ -135,9 +136,13 @@ public class InvestmentFdApiResource {
@Operation(summary = "Preview a maturity, premature closure or renewal", description = "type MATURITY | PREMATURE | RENEWAL") @Operation(summary = "Preview a maturity, premature closure or renewal", description = "type MATURITY | PREMATURE | RENEWAL")
public InvestmentFdClosureData closureQuote(@PathParam("fdId") final Long fdId, @QueryParam("type") final String type, public InvestmentFdClosureData closureQuote(@PathParam("fdId") final Long fdId, @QueryParam("type") final String type,
@QueryParam("closeDate") final String closeDate, @QueryParam("receivedAmount") final BigDecimal receivedAmount, @QueryParam("closeDate") final String closeDate, @QueryParam("receivedAmount") final BigDecimal receivedAmount,
@QueryParam("tds") final BigDecimal tds, @QueryParam("renewalAmount") final BigDecimal renewalAmount) { @QueryParam("tds") final BigDecimal tds, @QueryParam("renewalAmount") final BigDecimal renewalAmount,
@QueryParam("principalBankGlAccountId") final Long principalBankGlAccountId,
@QueryParam("interestBankGlAccountId") final Long interestBankGlAccountId,
@QueryParam("diffBankGlAccountId") final Long diffBankGlAccountId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY); this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.closureService.quote(fdId, type, date(closeDate), receivedAmount, tds, renewalAmount); return this.closureService.quote(fdId, type, date(closeDate), receivedAmount, tds, renewalAmount, principalBankGlAccountId,
interestBankGlAccountId, diffBankGlAccountId);
} }
@POST @POST
@@ -181,6 +186,14 @@ public class InvestmentFdApiResource {
} }
private static LocalDate date(final String value) { private static LocalDate date(final String value) {
return StringUtils.isBlank(value) ? null : LocalDate.parse(value.trim(), DATE); if (StringUtils.isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim(), DATE);
} catch (final java.time.format.DateTimeParseException e) {
// A half-typed date is a bad request, not a server error.
throw new GeneralPlatformDomainRuleException("error.msg.investmentfd.date.invalid", "Enter the date as DD-MM-YYYY", value);
}
} }
} }
@@ -39,6 +39,8 @@ public class InvestmentFdData {
private final Long partyId; private final Long partyId;
private final String partyCode; private final String partyCode;
private final String partyName; private final String partyName;
/** TDS rate the bank deducts on interest; null when it does not deduct TDS. */
private final BigDecimal tdsRate;
private final String certNo; private final String certNo;
private final String certDate; private final String certDate;
private final String dealDate; private final String dealDate;
@@ -76,7 +78,9 @@ public class InvestmentFdData {
public static InvestmentFdData from(final InvestmentFd f, final BigDecimal periodInterest) { public static InvestmentFdData from(final InvestmentFd f, final BigDecimal periodInterest) {
return builder().id(f.getId()).dealNo(f.getDealNo()).partyId(f.getParty().getId()).partyCode(f.getParty().getPartyCode()) return builder().id(f.getId()).dealNo(f.getDealNo()).partyId(f.getParty().getId()).partyCode(f.getParty().getPartyCode())
.partyName(f.getParty().getPartyName()).certNo(f.getCertNo()).certDate(format(f.getCertDate())) .partyName(f.getParty().getPartyName())
.tdsRate(f.getParty().isTdsApplicable() ? f.getParty().getTdsRate() : null).certNo(f.getCertNo())
.certDate(format(f.getCertDate()))
.dealDate(format(f.getDealDate())).principal(f.getPrincipal()).intRate(f.getIntRate()) .dealDate(format(f.getDealDate())).principal(f.getPrincipal()).intRate(f.getIntRate())
.maturityDate(format(f.getMaturityDate())).days(ChronoUnit.DAYS.between(f.getDealDate(), f.getMaturityDate())) .maturityDate(format(f.getMaturityDate())).days(ChronoUnit.DAYS.between(f.getDealDate(), f.getMaturityDate()))
.payout(f.getPayout()).calcType(f.getCalcType()).expectedInterest(f.getExpectedInterest()) .payout(f.getPayout()).calcType(f.getCalcType()).expectedInterest(f.getExpectedInterest())
@@ -45,6 +45,8 @@ public class InvestmentPartyData {
private final Long interestGlAccountId; private final Long interestGlAccountId;
private final String interestGlCode; private final String interestGlCode;
private final String interestGlName; private final String interestGlName;
private final boolean tdsApplicable;
private final BigDecimal tdsRate;
private final String rbiCode; private final String rbiCode;
private final BigDecimal limit; private final BigDecimal limit;
private final String leiNo; private final String leiNo;
@@ -62,7 +64,8 @@ public class InvestmentPartyData {
.interestGlAccountId(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getId()) .interestGlAccountId(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getId())
.interestGlCode(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getGlCode()) .interestGlCode(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getGlCode())
.interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName()) .interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName())
.rbiCode(p.getRbiCode()).limit(p.getExposureLimit()).leiNo(p.getLeiNo()).cpBankName(p.getCpBankName()) .tdsApplicable(p.isTdsApplicable()).tdsRate(p.getTdsRate()).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(); .cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc()).active(p.isActive()).build();
} }
} }
@@ -83,6 +83,14 @@ public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom<Long
@JoinColumn(name = "interest_gl_account_id") @JoinColumn(name = "interest_gl_account_id")
private GLAccount interestGlAccount; private GLAccount interestGlAccount;
/** Whether this bank deducts TDS from the interest on FDs placed with it (some banks do, some do not). */
@Column(name = "tds_applicable", nullable = false)
private boolean tdsApplicable;
/** TDS rate in per cent, set only when the bank deducts TDS. */
@Column(name = "tds_rate", scale = 4, precision = 9)
private BigDecimal tdsRate;
@Column(name = "rbi_code", length = 30) @Column(name = "rbi_code", length = 30)
private String rbiCode; private String rbiCode;
@@ -53,6 +53,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_PARAMS; 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_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPES; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_RATE;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
@@ -122,6 +123,10 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer {
final BigDecimal limit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(EXPOSURE_LIMIT, element); final BigDecimal limit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(EXPOSURE_LIMIT, element);
v.reset().parameter(EXPOSURE_LIMIT).value(limit).ignoreIfNull().zeroOrPositiveAmount(); v.reset().parameter(EXPOSURE_LIMIT).value(limit).ignoreIfNull().zeroOrPositiveAmount();
} }
if (this.fromApiJsonHelper.parameterExists(TDS_RATE, element)) {
final BigDecimal rate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_RATE, element);
v.reset().parameter(TDS_RATE).value(rate).ignoreIfNull().positiveAmount().notGreaterThanMax(BigDecimal.valueOf(100));
}
throwIfErrors(errors); throwIfErrors(errors);
} }
@@ -111,12 +111,17 @@ public class InvestmentFdClosureService {
/** Preview of a closure; nothing is posted. */ /** Preview of a closure; nothing is posted. */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InvestmentFdClosureData quote(final Long fdId, final String type, final LocalDate closeDate, final BigDecimal receivedAmount, public InvestmentFdClosureData quote(final Long fdId, final String type, final LocalDate closeDate, final BigDecimal receivedAmount,
final BigDecimal tds, final BigDecimal renewalAmount) { final BigDecimal tds, final BigDecimal renewalAmount, final Long principalBankGlId, final Long interestBankGlId,
final Long diffBankGlId) {
final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist")); final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist"));
final String t = CLOSURE_PREMATURE.equals(type) || CLOSURE_RENEWAL.equals(type) ? type : CLOSURE_MATURITY; final String t = CLOSURE_PREMATURE.equals(type) || CLOSURE_RENEWAL.equals(type) ? type : CLOSURE_MATURITY;
final LocalDate date = CLOSURE_PREMATURE.equals(t) ? (closeDate == null ? DateUtils.getBusinessLocalDate() : closeDate) : f.getMaturityDate(); final LocalDate date = CLOSURE_PREMATURE.equals(t) ? (closeDate == null ? DateUtils.getBusinessLocalDate() : closeDate) : f.getMaturityDate();
final Figures x = figures(f, t, date, receivedAmount, tds, renewalAmount); final Figures x = figures(f, t, date, receivedAmount, tds, renewalAmount);
final Entry e = CLOSURE_RENEWAL.equals(t) ? renewalEntry(f, x, f.getBankGlAccount()) : closureEntry(f, x, f.getBankGlAccount(), f.getBankGlAccount()); // The banks the user has chosen, as the posting will use them; a renewal difference shows no bank line until one is chosen.
final GLAccount principalGl = principalBankGlId == null ? f.getBankGlAccount() : bankGl(principalBankGlId, f);
final GLAccount interestGl = interestBankGlId == null ? principalGl : bankGl(interestBankGlId, f);
final Entry e = CLOSURE_RENEWAL.equals(t) ? renewalEntry(f, x, diffBankGlId == null ? null : bankGl(diffBankGlId, f))
: closureEntry(f, x, principalGl, interestGl);
return x.toData(f, t, date, null, e); return x.toData(f, t, date, null, e);
} }
@@ -328,7 +333,10 @@ public class InvestmentFdClosureService {
} }
final BigDecimal expected = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, date, f.getCalcType(), final BigDecimal expected = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, date, f.getCalcType(),
f.getPayout()); f.getPayout());
final BigDecimal tds = scale(zero(tdsIn)); // TDS as entered; otherwise worked out at the bank's TDS rate, from the bank's net figure when given.
final BigDecimal tds = tdsIn != null ? scale(tdsIn)
: amountIn != null ? InvestmentFdService.tdsInNet(f.getParty(), scale(amountIn).subtract(f.getPrincipal()))
: InvestmentFdService.tdsOnGross(f.getParty(), expected);
// Maturity / renewal default to the system's figure; a premature closure always needs the bank's amount. // Maturity / renewal default to the system's figure; a premature closure always needs the bank's amount.
final BigDecimal received = amountIn != null ? scale(amountIn) : f.getPrincipal().add(expected).subtract(tds); final BigDecimal received = amountIn != null ? scale(amountIn) : f.getPrincipal().add(expected).subtract(tds);
final BigDecimal interest = received.add(tds).subtract(f.getPrincipal()); final BigDecimal interest = received.add(tds).subtract(f.getPrincipal());
@@ -176,11 +176,15 @@ public class InvestmentFdInterestService {
x.setAmount(next.amount()); x.setAmount(next.amount());
what = "FD interest provision"; what = "FD interest provision";
} else { } else {
// The bank's own figure when given (net of TDS), else the system's interest for the period. // The bank's own figure when given (net of TDS), else the system's interest for the period. TDS as entered, else
final BigDecimal tds = amount(command, TDS); // worked out at the bank's TDS rate.
final BigDecimal received = command.parameterExists(RECEIVED_AMOUNT) && command.bigDecimalValueOfParameterNamed(RECEIVED_AMOUNT) != null final boolean bankFigure = command.parameterExists(RECEIVED_AMOUNT)
? amount(command, RECEIVED_AMOUNT) && command.bigDecimalValueOfParameterNamed(RECEIVED_AMOUNT) != null;
: next.gross().subtract(tds); final boolean tdsGiven = command.parameterExists(TDS) && command.bigDecimalValueOfParameterNamed(TDS) != null;
final BigDecimal tds = tdsGiven ? amount(command, TDS)
: bankFigure ? InvestmentFdService.tdsInNet(f.getParty(), amount(command, RECEIVED_AMOUNT))
: InvestmentFdService.tdsOnGross(f.getParty(), next.gross());
final BigDecimal received = bankFigure ? amount(command, RECEIVED_AMOUNT) : next.gross().subtract(tds);
final BigDecimal gross = received.add(tds); final BigDecimal gross = received.add(tds);
final BigDecimal income = gross.subtract(next.alreadyBooked()); final BigDecimal income = gross.subtract(next.alreadyBooked());
final GLAccount bank = command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID) == null ? f.getBankGlAccount() final GLAccount bank = command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID) == null ? f.getBankGlAccount()
@@ -260,7 +264,8 @@ public class InvestmentFdInterestService {
final BigDecimal gross = done != null ? done.getGrossInterest() final BigDecimal gross = done != null ? done.getGrossInterest()
: InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, d, CALC_SIMPLE, f.getPayout()); : InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, d, CALC_SIMPLE, f.getPayout());
final BigDecimal booked = done != null ? zero(done.getAlreadyBooked()) : sincePaid; final BigDecimal booked = done != null ? zero(done.getAlreadyBooked()) : sincePaid;
out.add(new Line(type, d, lastPaid, gross, booked, done == null ? null : done.getTds(), final BigDecimal tds = done == null ? InvestmentFdService.tdsOnGross(f.getParty(), gross) : done.getTds();
out.add(new Line(type, d, lastPaid, gross, booked, tds,
done != null ? done.getAmount() : gross.subtract(booked), done)); done != null ? done.getAmount() : gross.subtract(booked), done));
lastPaid = d; lastPaid = d;
sincePaid = BigDecimal.ZERO; sincePaid = BigDecimal.ZERO;
@@ -287,6 +287,25 @@ public class InvestmentFdService {
return PAYOUT_AT_MATURITY.equals(payout) ? amount.add(interest) : amount; return PAYOUT_AT_MATURITY.equals(payout) ? amount.add(interest) : amount;
} }
/** TDS the bank deducts from a gross interest at the rate on its FD party; zero when the bank does not deduct TDS. */
static BigDecimal tdsOnGross(final InvestmentParty party, final BigDecimal grossInterest) {
if (!party.isTdsApplicable() || party.getTdsRate() == null || grossInterest == null || grossInterest.signum() <= 0) {
return BigDecimal.ZERO;
}
return grossInterest.multiply(party.getTdsRate()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
/** TDS already taken out of an interest the bank paid net of it: gross = net × 100 / (100 − rate), TDS = gross − net. */
static BigDecimal tdsInNet(final InvestmentParty party, final BigDecimal netInterest) {
final BigDecimal hundred = BigDecimal.valueOf(100);
if (!party.isTdsApplicable() || party.getTdsRate() == null || party.getTdsRate().compareTo(hundred) >= 0 || netInterest == null
|| netInterest.signum() <= 0) {
return BigDecimal.ZERO;
}
final BigDecimal gross = netInterest.multiply(hundred).divide(hundred.subtract(party.getTdsRate()), 2, RoundingMode.HALF_UP);
return gross.subtract(netInterest).setScale(2, RoundingMode.HALF_UP);
}
/** The 31 March of the previous financial year: for 16-10-2026 → 31-03-2026, for 31-03-2026 → 31-03-2025. */ /** The 31 March of the previous financial year: for 16-10-2026 → 31-03-2026, for 31-03-2026 → 31-03-2025. */
static LocalDate previousYearEnd(final LocalDate date) { static LocalDate previousYearEnd(final LocalDate date) {
final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
@@ -66,14 +66,14 @@ public final class InvestmentInterestCalculator {
return (to.getYear() - from.getYear()) * 360 + (to.getMonthValue() - from.getMonthValue()) * 30 + (d2 - d1); 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. */ /** Interest on {@code faceValue} at {@code ratePercent} p.a. for {@code days} (30/360), to the paisa. */
public static BigDecimal interestForDays(final BigDecimal faceValue, final BigDecimal ratePercent, final int days) { 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); return faceValue.multiply(ratePercent).multiply(BigDecimal.valueOf(days)).divide(THIRTY_SIX_THOUSAND, 2, RoundingMode.HALF_UP);
} }
/** The fixed half-yearly coupon: face value × rate ÷ 2. */ /** The fixed half-yearly coupon: face value × rate ÷ 2, to the paisa. */
public static BigDecimal halfYearCoupon(final BigDecimal faceValue, final BigDecimal ratePercent) { public static BigDecimal halfYearCoupon(final BigDecimal faceValue, final BigDecimal ratePercent) {
return faceValue.multiply(ratePercent).divide(TWO_HUNDRED, 0, RoundingMode.HALF_UP); return faceValue.multiply(ratePercent).divide(TWO_HUNDRED, 2, RoundingMode.HALF_UP);
} }
/** k-th coupon date counting back from maturity — measured from maturity each time so month-end days survive. */ /** k-th coupon date counting back from maturity — measured from maturity each time so month-end days survive. */
@@ -49,6 +49,8 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX; 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.SCRIPT_DESC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_APPLICABLE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_RATE;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
@@ -160,6 +162,19 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
final Long interestGlId = command.longValueOfParameterNamed(INTEREST_GL_ACCOUNT_ID); final Long interestGlId = command.longValueOfParameterNamed(INTEREST_GL_ACCOUNT_ID);
p.setInterestGlAccount(interestGlId == null ? null : postableIncomeGl(interestGlId)); p.setInterestGlAccount(interestGlId == null ? null : postableIncomeGl(interestGlId));
} }
if (command.parameterExists(TDS_APPLICABLE)) {
p.setTdsApplicable(command.booleanPrimitiveValueOfParameterNamed(TDS_APPLICABLE));
}
if (command.parameterExists(TDS_RATE)) {
p.setTdsRate(command.bigDecimalValueOfParameterNamed(TDS_RATE));
}
// A bank that deducts TDS needs its rate; one that does not keeps no rate.
if (!p.isTdsApplicable()) {
p.setTdsRate(null);
} else if (p.getTdsRate() == null || p.getTdsRate().signum() <= 0) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.party.tds.rate.required",
"Enter the TDS rate this bank deducts on FD interest");
}
} }
/** Interest on an FD with this bank is credited here, so it must be a usable Income account. */ /** Interest on an FD with this bank is credited here, so it must be a usable Income account. */
@@ -184,4 +184,5 @@
<include file="parts/0602_investment_security_transfer.xml" relativeToChangelogFile="true"/> <include file="parts/0602_investment_security_transfer.xml" relativeToChangelogFile="true"/>
<include file="parts/0603_investment_premium_amortisation.xml" relativeToChangelogFile="true"/> <include file="parts/0603_investment_premium_amortisation.xml" relativeToChangelogFile="true"/>
<include file="parts/0604_investment_fd.xml" relativeToChangelogFile="true"/> <include file="parts/0604_investment_fd.xml" relativeToChangelogFile="true"/>
<include file="parts/0605_investment_party_tds.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,42 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Some banks deduct TDS from the interest on the bank's FDs with them and some do not, so the FD party says whether
it does and at what rate. The rate pre-fills the TDS on interest payouts and closures; the user can still change it.
-->
<changeSet author="cbs-anshul" id="0605-1-investment-party-tds">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_party" columnName="tds_applicable"/></not>
</preConditions>
<addColumn tableName="m_investment_party">
<column name="tds_applicable" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="tds_rate" type="DECIMAL(9,4)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -60,16 +60,16 @@ class InvestmentInterestCalculatorTest {
} }
@Test @Test
void interestForDaysRoundsToTheRupee() { void interestForDaysIsToThePaisa() {
assertEquals(new BigDecimal("120833"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 58)); assertEquals(new BigDecimal("120833.33"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 58));
assertEquals(new BigDecimal("104167"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 50)); assertEquals(new BigDecimal("104166.67"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 50));
assertEquals(new BigDecimal("375000"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 180)); assertEquals(new BigDecimal("375000.00"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 180));
} }
@Test @Test
void halfYearCouponIsFaceValueTimesRateOverTwo() { void halfYearCouponIsFaceValueTimesRateOverTwo() {
assertEquals(new BigDecimal("375000"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, RATE)); assertEquals(new BigDecimal("375000.00"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, RATE));
assertEquals(new BigDecimal("362500"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, new BigDecimal("7.25"))); assertEquals(new BigDecimal("362500.00"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, new BigDecimal("7.25")));
} }
@Test @Test
@@ -106,14 +106,14 @@ class InvestmentInterestCalculatorTest {
// 16 coupons + 15 closings (Mar 2027 … Mar 2034); 30-09-2026 is before the deal, so it is the seller's. // 16 coupons + 15 closings (Mar 2027 … Mar 2034); 30-09-2026 is before the deal, so it is the seller's.
assertEquals(31, rows.size()); assertEquals(31, rows.size());
// First coupon is income in full: the 1,20,833 paid to the seller was booked as an expense at purchase (RBI // First coupon is income in full: the 1,20,833.33 paid to the seller was booked as an expense at purchase (RBI
// para 152). // para 152).
assertRow(rows.get(0), TYPE_COUPON, LocalDate.of(2027, 2, 10), LocalDate.of(2026, 8, 11), 180, "375000", "0", "375000"); assertRow(rows.get(0), TYPE_COUPON, LocalDate.of(2027, 2, 10), LocalDate.of(2026, 8, 11), 180, "375000.00", "0", "375000.00");
// 31 March closing: 50 days earned since 10 Feb, nothing booked yet. // 31 March closing: 50 days earned since 10 Feb, nothing booked yet.
assertRow(rows.get(1), TYPE_PROVISION, LocalDate.of(2027, 3, 31), LocalDate.of(2027, 2, 11), 50, "104167", "0", "104167"); assertRow(rows.get(1), TYPE_PROVISION, LocalDate.of(2027, 3, 31), LocalDate.of(2027, 2, 11), 50, "104166.67", "0", "104166.67");
// August coupon clears the March provision. // August coupon clears the March provision.
assertRow(rows.get(2), TYPE_COUPON, LocalDate.of(2027, 8, 10), LocalDate.of(2027, 2, 11), 180, "375000", "104167", "270833"); assertRow(rows.get(2), TYPE_COUPON, LocalDate.of(2027, 8, 10), LocalDate.of(2027, 2, 11), 180, "375000.00", "104166.67", "270833.33");
assertRow(rows.get(3), TYPE_PROVISION, LocalDate.of(2027, 9, 30), LocalDate.of(2027, 8, 11), 50, "104167", "0", "104167"); assertRow(rows.get(3), TYPE_PROVISION, LocalDate.of(2027, 9, 30), LocalDate.of(2027, 8, 11), 50, "104166.67", "0", "104166.67");
final ScheduleRow last = rows.get(rows.size() - 1); final ScheduleRow last = rows.get(rows.size() - 1);
assertEquals(TYPE_COUPON, last.type()); assertEquals(TYPE_COUPON, last.type());
@@ -121,7 +121,7 @@ class InvestmentInterestCalculatorTest {
// Over the life, income booked = all the coupons. // Over the life, income booked = all the coupons.
final BigDecimal totalIncome = rows.stream().map(ScheduleRow::amount).reduce(BigDecimal.ZERO, BigDecimal::add); final BigDecimal totalIncome = rows.stream().map(ScheduleRow::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
assertEquals(new BigDecimal("375000").multiply(BigDecimal.valueOf(16)), totalIncome); assertEquals(0, new BigDecimal("375000").multiply(BigDecimal.valueOf(16)).compareTo(totalIncome));
} }
@Test @Test