feat(deposits,clients): FD Special auto-configuration, interest schedule projection, and minor-age backend mirror
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 - PostgreSQL / test (test-core-2) (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-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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (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 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract 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 - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract 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 - PostgreSQL / test (test-core-2) (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-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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (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 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract 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 - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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
Three related pieces of backend support for this session's FD Special
(monthly interest payout) work, plus one more instance of the same
transaction-ordering bug fixed elsewhere this session.
1. FD Special account creation/modification auto-configuration
FD Special (fdType=SPECIAL) is a non-cumulative product: interest must
post monthly and leave for a linked savings account, and maturity must
auto-transfer the principal out -- regardless of whatever compounding/
posting period or maturity instruction the request otherwise carries.
Previously none of this was enforced, so a SPECIAL account could be
created with e.g. quarterly compounding, or a later "modify application"
request could silently unset transferInterestToLinkedAccount / the
maturity instruction / the linked account by mere omission of those
fields from the payload, while the account kept displaying as "FD
Special".
- DepositAccountAssembler: on create, forces interestCompoundingPeriodType
and interestPostingPeriodType to MONTHLY, transferInterestToSavings to
true, transferToSavingsId to the (now required) linked savings account,
and accountOnClosureType to TRANSFER_TO_SAVINGS, whenever fdType is
SPECIAL. Throws a clear domain error if no linked account was selected.
- DepositAccountTermAndPreClosure: new forceMonthlyPayoutToLinkedSavings(),
re-applying those same three standing-instruction fields.
- DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl: calls
the equivalent re-enforcement on every modify-application request for
a SPECIAL account, after the linked-account resolution in that same
method has settled what account (if any) ends up linked.
2. FD Special projected interest schedule (engine + API)
Staff/customers previously only saw a flat rate/12 approximation of
monthly interest. Added a real, engine-computed month-by-month schedule:
- DepositAccountWritePlatformService(Impl): new
retrieveProjectedInterestSchedule(), running the same PostingPeriod
engine the real maturity calculation uses (FixedDepositAccount#
projectInterestSchedule), side-effect-free (@Transactional(readOnly)),
clipping the last period's displayed end-date to maturityDate-1 (the
engine otherwise rounds it out to the next calendar boundary even when
maturity falls partway through), and flagging each period "paid" by
matching real posted-interest transaction dates.
- FixedDepositAccountsApiResource: computes and attaches the schedule,
plus server-computed totalProjectedInterest/totalValueAtMaturity, to
the FD template response for SPECIAL accounts only.
- FixedDepositAccountData / DepositsApiConstants: carrier fields and
response-parameter whitelist entries for the three new fields.
3. Minor-age threshold: backend now matches the frontend's shared constant
Companion to this session's frontend minor-age-threshold change
(18 -> 10, see client-age.util.ts). ClientKycApiResource's minor
detection (which gates the required-KYC-document list, including
whether PAN is mandatory) was still hardcoded to age < 18. Added
ClientApiConstants.MINOR_AGE_THRESHOLD (10) and SENIOR_CITIZEN_AGE_
THRESHOLD (60) as the backend's own named constants, and switched the
KYC endpoint to use MINOR_AGE_THRESHOLD, so the two sides can't silently
disagree on who counts as a minor.
4. SavingsAccountTransactionComparator: same-date tie-break fixes another
instance of this session's core reversal bug
For a Special FD's interest-transfer sweep, the withdrawal shares the
exact transaction_date of the interest posting it pays out. When that
posting is reversed and recreated, the new posting transaction gets a
higher id than the already-existing withdrawal; the comparator's id-order
tiebreaker then sorted the withdrawal before the new posting, which
understated the running balance shown between them (e.g. showing
Rs.99,901.39 instead of Rs.100,000.00 immediately after the pair). Added
an explicit tiebreaker so a same-date, same-createdDate interest posting
always sorts before a withdrawal/debit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+9
@@ -162,6 +162,15 @@ public class ClientApiConstants {
|
||||
public static final String ageDaysParamName = "ageDays";
|
||||
public static final String nationalityIdParamName = "nationalityId";
|
||||
public static final String isMinorParamName = "isMinor";
|
||||
/**
|
||||
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
|
||||
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
|
||||
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
|
||||
* list (with mandatory PAN) for a client the UI classifies as a minor.
|
||||
*/
|
||||
public static final int MINOR_AGE_THRESHOLD = 10;
|
||||
/** At or above this age a client qualifies for senior-citizen deposit rates. */
|
||||
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
|
||||
public static final String guardianClientIdParamName = "guardianClientId";
|
||||
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
||||
public static final String guardianRelationParamName = "guardianRelation";
|
||||
|
||||
+4
@@ -589,6 +589,10 @@ public final class DepositsApiConstants {
|
||||
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
||||
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
||||
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
||||
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
|
||||
fixedDepositResponseData.add("projectedInterestSchedule");
|
||||
fixedDepositResponseData.add("totalProjectedInterest");
|
||||
fixedDepositResponseData.add("totalValueAtMaturity");
|
||||
return fixedDepositResponseData;
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -160,10 +160,13 @@ public class ClientKycApiResource {
|
||||
template.put("clientTypeName", clientTypeName);
|
||||
template.put("legalFormId", legalFormId);
|
||||
|
||||
// Minor detection mirrors the frontend's own rule (age < 18) so the document list
|
||||
// served here stays consistent with what the UI computes from the same date of birth.
|
||||
// Minor detection mirrors the frontend's own rule so the document list served here stays
|
||||
// consistent with what the UI computes from the same date of birth. Both sides read the
|
||||
// threshold from a single named constant -- when they disagree, this endpoint wins and would
|
||||
// silently serve an adult document list (mandatory PAN) to a minor.
|
||||
final LocalDate dateOfBirth = client.dateOfBirth();
|
||||
final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18;
|
||||
final boolean isMinor = dateOfBirth != null
|
||||
&& Period.between(dateOfBirth, LocalDate.now()).getYears() < ClientApiConstants.MINOR_AGE_THRESHOLD;
|
||||
template.put("isMinor", isMinor);
|
||||
|
||||
template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor));
|
||||
|
||||
+24
@@ -81,6 +81,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.FixedDepositAccountInterestCalculationService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService;
|
||||
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
|
||||
@@ -107,6 +108,7 @@ public class FixedDepositAccountsApiResource {
|
||||
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
|
||||
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
|
||||
private final SqlValidator sqlValidator;
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
@@ -245,6 +247,28 @@ public class FixedDepositAccountsApiResource {
|
||||
final FixedDepositAccountData accountTemplate = populateTemplateAndAssociations(accountId, account, staffInSelectedOfficeOnly,
|
||||
chargeStatus, uriInfo, mandatoryResponseParameters);
|
||||
accountTemplate.setActivationCharge(getActivationCharge(accountId));
|
||||
// FD Special customers/staff need to see exactly how much interest is projected each month across
|
||||
// the whole term, not just a flat rate/12 approximation -- computed on demand from the real
|
||||
// interest engine (FixedDepositAccount#projectInterestSchedule), side-effect-free, so this GET can
|
||||
// safely recompute it on every request. SINGLE/DOUBLE FDs never get this field.
|
||||
if ("SPECIAL".equalsIgnoreCase(account.getFdType())) {
|
||||
final java.util.List<java.util.Map<String, Object>> schedule = this.depositAccountWritePlatformService
|
||||
.retrieveProjectedInterestSchedule(accountId, DepositAccountType.FIXED_DEPOSIT);
|
||||
accountTemplate.setProjectedInterestSchedule(schedule);
|
||||
// Computed once here -- the single source of truth for "what does the customer end up with
|
||||
// overall" -- rather than the frontend re-summing the schedule array itself, the same way
|
||||
// maturityAmount itself is always a backend-computed field, never a client-side derivation.
|
||||
BigDecimal totalInterest = BigDecimal.ZERO;
|
||||
for (final java.util.Map<String, Object> period : schedule) {
|
||||
final Object amount = period.get("interestAmount");
|
||||
if (amount instanceof BigDecimal) {
|
||||
totalInterest = totalInterest.add((BigDecimal) amount);
|
||||
}
|
||||
}
|
||||
final BigDecimal principal = accountTemplate.getDepositAmount();
|
||||
final BigDecimal totalValue = principal != null ? principal.add(totalInterest) : totalInterest;
|
||||
accountTemplate.setProjectedInterestTotals(totalInterest, totalValue);
|
||||
}
|
||||
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters(),
|
||||
mandatoryResponseParameters);
|
||||
return this.toApiJsonSerializer.serialize(settings, accountTemplate,
|
||||
|
||||
+43
-6
@@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.expecte
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.linkedAccountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName;
|
||||
@@ -68,6 +69,7 @@ import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
@@ -248,6 +250,14 @@ public class DepositAccountAssembler {
|
||||
}
|
||||
}
|
||||
|
||||
// FD Special (fdType=SPECIAL) is a non-cumulative, monthly-interest-payout product: interest must
|
||||
// post every month and leave the account for its linked savings account (see
|
||||
// assembleAccountTermAndPreClosure below and the maturity-instruction override there), regardless
|
||||
// of whatever compounding/posting period the product or the request otherwise specifies. SINGLE and
|
||||
// DOUBLE FD types are untouched by this flag.
|
||||
final boolean isSpecialFixedDeposit = depositAccountType.isFixedDeposit() && product instanceof FixedDepositProduct
|
||||
&& "SPECIAL".equalsIgnoreCase(((FixedDepositProduct) product).getFdType());
|
||||
|
||||
SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null;
|
||||
final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName);
|
||||
if (interestPeriodTypeValue != null) {
|
||||
@@ -255,6 +265,13 @@ public class DepositAccountAssembler {
|
||||
} else {
|
||||
interestCompoundingPeriodType = product.interestCompoundingPeriodType();
|
||||
}
|
||||
if (isSpecialFixedDeposit) {
|
||||
// Keep compounding no coarser than the forced Monthly posting below -- compounded interest gets
|
||||
// reset to zero every period anyway once transferInterestToLinkedAccount is true (see
|
||||
// CompoundInterestHelper), so this is only about keeping the interest engine's own
|
||||
// posting/compounding-period expectations internally consistent, not a behaviour choice.
|
||||
interestCompoundingPeriodType = SavingsCompoundingInterestPeriodType.MONTHLY;
|
||||
}
|
||||
|
||||
SavingsPostingInterestPeriodType interestPostingPeriodType = null;
|
||||
final Integer interestPostingPeriodTypeValue = command.integerValueOfParameterNamed(interestPostingPeriodTypeParamName);
|
||||
@@ -263,6 +280,9 @@ public class DepositAccountAssembler {
|
||||
} else {
|
||||
interestPostingPeriodType = product.interestPostingPeriodType();
|
||||
}
|
||||
if (isSpecialFixedDeposit) {
|
||||
interestPostingPeriodType = SavingsPostingInterestPeriodType.MONTHLY;
|
||||
}
|
||||
|
||||
SavingsInterestCalculationType interestCalculationType = null;
|
||||
final Integer interestCalculationTypeValue = command.integerValueOfParameterNamed(interestCalculationTypeParamName);
|
||||
@@ -366,7 +386,7 @@ public class DepositAccountAssembler {
|
||||
if (depositAccountType.isFixedDeposit()) {
|
||||
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((FixedDepositProduct) product).depositProductTermAndPreClosure();
|
||||
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
|
||||
prodTermAndPreClosure);
|
||||
prodTermAndPreClosure, isSpecialFixedDeposit);
|
||||
|
||||
FixedDepositAccount fdAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, fieldOfficer,
|
||||
accountNo, externalIdFactory.create(externalId), accountType, submittedOnDate, submittedBy, interestRate,
|
||||
@@ -380,7 +400,7 @@ public class DepositAccountAssembler {
|
||||
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((RecurringDepositProduct) product)
|
||||
.depositProductTermAndPreClosure();
|
||||
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
|
||||
prodTermAndPreClosure);
|
||||
prodTermAndPreClosure, false);
|
||||
|
||||
final DepositProductRecurringDetail prodRecurringDetail = ((RecurringDepositProduct) product).depositRecurringDetail();
|
||||
final DepositAccountRecurringDetail accountRecurringDetail = this.assembleAccountRecurringDetail(command,
|
||||
@@ -420,7 +440,7 @@ public class DepositAccountAssembler {
|
||||
}
|
||||
|
||||
public DepositAccountTermAndPreClosure assembleAccountTermAndPreClosure(final JsonCommand command,
|
||||
final DepositProductTermAndPreClosure productTermAndPreclosure) {
|
||||
final DepositProductTermAndPreClosure productTermAndPreclosure, final boolean forceMonthlyPayoutToLinkedSavings) {
|
||||
final DepositPreClosureDetail productPreClosure = (productTermAndPreclosure == null) ? null
|
||||
: productTermAndPreclosure.depositPreClosureDetail();
|
||||
final DepositTermDetail productTerm = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositTermDetail();
|
||||
@@ -435,17 +455,34 @@ public class DepositAccountAssembler {
|
||||
final SavingsPeriodFrequencyType depositPeriodFrequency = SavingsPeriodFrequencyType.fromInt(depositPeriodFrequencyId);
|
||||
final SavingsAccount account = null;
|
||||
final LocalDate expectedFirstDepositOnDate = command.localDateValueOfParameterNamed(expectedFirstDepositOnDateParamName);
|
||||
final Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName);
|
||||
Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName);
|
||||
|
||||
// calculate maturity amount
|
||||
final BigDecimal maturityAmount = null;// calculated and updated in
|
||||
// account
|
||||
final LocalDate maturityDate = null;// calculated and updated in account
|
||||
final Integer accountOnClosureTypeId = command.integerValueOfParameterNamed(maturityInstructionIdParamName);
|
||||
final DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null
|
||||
DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null
|
||||
? DepositAccountOnClosureType.fromInt(accountOnClosureTypeId)
|
||||
: null;
|
||||
final Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName);
|
||||
Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName);
|
||||
|
||||
if (forceMonthlyPayoutToLinkedSavings) {
|
||||
final Long linkedSavingsAccountId = command.longValueOfParameterNamed(linkedAccountParamName);
|
||||
if (linkedSavingsAccountId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.special.requires.linked.savings.account",
|
||||
"FD Special (Monthly Interest Payout) requires a linked savings account to be selected.");
|
||||
}
|
||||
// Non-cumulative payout FD: every month's interest leaves for the linked savings account
|
||||
// (TransferInterestToSavingsTasklet) instead of compounding, and maturity re-uses the existing
|
||||
// "Transfer to Savings" maturity-instruction path (see
|
||||
// DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails) to move the
|
||||
// principal out and close the account automatically, with no staff action needed.
|
||||
trasferInterest = true;
|
||||
transferToSavingsId = linkedSavingsAccountId;
|
||||
accountOnClosureType = DepositAccountOnClosureType.TRANSFER_TO_SAVINGS;
|
||||
}
|
||||
|
||||
return DepositAccountTermAndPreClosure.createNew(updatedProductPreClosure, updatedProductTerm, account, depositAmount,
|
||||
maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, expectedFirstDepositOnDate, accountOnClosureType,
|
||||
trasferInterest, transferToSavingsId);
|
||||
|
||||
+67
@@ -107,6 +107,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrap
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
|
||||
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
|
||||
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
|
||||
import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException;
|
||||
@@ -561,6 +562,72 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* FD Special (monthly interest payout) shows customers/staff exactly how much interest is projected to
|
||||
* post each month across the account's whole term, rather than a flat rate/12 approximation -- computed
|
||||
* with the real PostingPeriod engine (same one the actual maturity calculation uses) via
|
||||
* FixedDepositAccount#projectInterestSchedule, which is deliberately side-effect-free. readOnly=true
|
||||
* overrides this class's default @Transactional so nothing from that computation is ever flushed.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveProjectedInterestSchedule(final Long savingsId, final DepositAccountType depositAccountType) {
|
||||
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
|
||||
if (!(account instanceof FixedDepositAccount)) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
|
||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||
final MathContext mc = new MathContext(15, MoneyHelper.getRoundingMode());
|
||||
|
||||
final FixedDepositAccount fdAccount = (FixedDepositAccount) account;
|
||||
final List<PostingPeriod> postingPeriods = fdAccount.projectInterestSchedule(mc, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
|
||||
// determineInterestPostingPeriods always rounds the LAST period out to the full natural boundary
|
||||
// (e.g. calendar month-end for Monthly posting) even when the account's own maturity date falls
|
||||
// partway through it -- the INTEREST AMOUNT for that period is still correctly computed only up to
|
||||
// the real cutoff (interestCalculationUpto = maturityDate - 1, the same day postMaturityInterest
|
||||
// clamps its own posting date to), but the period's displayed end-date is not, and would otherwise
|
||||
// show a "To Date" past the account's actual maturity date. Clip it here so the schedule shown
|
||||
// never claims interest for days beyond maturity, matching the money that is actually calculated.
|
||||
final LocalDate lastCalculableDate = fdAccount.maturityDate() != null ? fdAccount.maturityDate().minusDays(1) : null;
|
||||
|
||||
// A period is "paid" once a real (non-reversed) interest-posting transaction actually landed for it.
|
||||
// Matched EXACTLY against that period's own end date (Fineract can date the posting transaction
|
||||
// either on the period's own last day, or on the first day of the following period, depending on
|
||||
// the isSavingsInterestPostingAtCurrentPeriodEnd configuration) -- not a date RANGE, which would
|
||||
// wrongly also match the very next period (whose own start date is that same "day after").
|
||||
final List<LocalDate> postedInterestDates = new ArrayList<>();
|
||||
for (final SavingsAccountTransaction transaction : fdAccount.getTransactions()) {
|
||||
if (transaction.isInterestPostingAndNotReversed()) {
|
||||
postedInterestDates.add(transaction.getTransactionDate());
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> schedule = new ArrayList<>();
|
||||
for (final PostingPeriod period : postingPeriods) {
|
||||
final LocalDate fromDate = period.getPeriodInterval().startDate();
|
||||
LocalDate toDate = period.getPeriodInterval().endDate();
|
||||
if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) {
|
||||
toDate = lastCalculableDate;
|
||||
}
|
||||
final LocalDate toDateFinal = toDate;
|
||||
final boolean paid = postedInterestDates.stream()
|
||||
.anyMatch(postedDate -> postedDate.isEqual(toDateFinal) || postedDate.isEqual(toDateFinal.plusDays(1)));
|
||||
|
||||
final Map<String, Object> row = new HashMap<>();
|
||||
row.put("fromDate", fromDate);
|
||||
row.put("toDate", toDateFinal);
|
||||
row.put("interestAmount", period.getInterestEarned().getAmount());
|
||||
row.put("paid", paid);
|
||||
schedule.add(row);
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult postInterest(final Long savingsId, final DepositAccountType depositAccountType) {
|
||||
|
||||
+25
@@ -80,6 +80,7 @@ import org.apache.fineract.portfolio.group.exception.GroupNotFoundException;
|
||||
import org.apache.fineract.portfolio.note.domain.Note;
|
||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
|
||||
@@ -429,11 +430,18 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
final Long savingsAccountId = command.longValueOfParameterNamed(DepositsApiConstants.linkedAccountParamName);
|
||||
AccountAssociations accountAssociations = this.accountAssociationsRepository.findBySavingsIdAndType(accountId,
|
||||
AccountAssociationType.LINKED_ACCOUNT_ASSOCIATION.getValue());
|
||||
// Tracks whichever savings account ends up linked once this whole block finishes, across every
|
||||
// branch below (kept as-is, removed, or replaced) -- used afterwards to re-enforce FD Special's
|
||||
// "must always have a linked account" requirement regardless of what this request touched.
|
||||
Long resolvedLinkedAccountId = accountAssociations != null && accountAssociations.linkedSavingsAccount() != null
|
||||
? accountAssociations.linkedSavingsAccount().getId()
|
||||
: null;
|
||||
if (savingsAccountId == null) {
|
||||
if (accountAssociations != null) {
|
||||
if (this.fromJsonHelper.parameterExists(DepositsApiConstants.linkedAccountParamName, command.parsedJson())) {
|
||||
this.accountAssociationsRepository.delete(accountAssociations);
|
||||
changes.put(DepositsApiConstants.linkedAccountParamName, null);
|
||||
resolvedLinkedAccountId = null;
|
||||
if (isLinkedAccRequired) {
|
||||
this.depositAccountDataValidator.throwLinkedAccountRequiredError();
|
||||
}
|
||||
@@ -465,6 +473,23 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
changes.put(DepositsApiConstants.linkedAccountParamName, savingsAccountId);
|
||||
this.accountAssociationsRepository.save(accountAssociations);
|
||||
}
|
||||
resolvedLinkedAccountId = savingsAccountId;
|
||||
}
|
||||
|
||||
// FD Special is a non-cumulative, monthly-interest-payout product: DepositAccountTermAndPreClosure#update
|
||||
// above has no fdType awareness and will happily unset transferInterestToLinkedAccount,
|
||||
// onAccountClosureType and transferToSavingsAccountId on this same request -- including by mere
|
||||
// omission of maturityInstructionId/transferToSavingsId from the payload -- silently turning a SPECIAL
|
||||
// FD into a plain cumulative one while it still displays as "FD Special". Re-force the three fields
|
||||
// here, now that the linked-account resolution above has settled what account (if any) is linked.
|
||||
if ("SPECIAL".equalsIgnoreCase(account.fdType())) {
|
||||
if (resolvedLinkedAccountId == null) {
|
||||
this.depositAccountDataValidator.throwLinkedAccountRequiredError();
|
||||
}
|
||||
account.forceSpecialFdMonthlyPayoutSettings(resolvedLinkedAccountId);
|
||||
changes.put(transferInterestToSavingsParamName, true);
|
||||
changes.put(DepositsApiConstants.maturityInstructionIdParamName, DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue());
|
||||
this.savingAccountRepository.save(account);
|
||||
}
|
||||
|
||||
// Replace nominees if provided
|
||||
|
||||
+25
@@ -182,6 +182,31 @@ public final class FixedDepositAccountData extends DepositAccountData {
|
||||
this.renewalHistory = renewalHistory;
|
||||
}
|
||||
|
||||
/**
|
||||
* FD Special only: the real, engine-computed interest schedule across the account's whole term -- one entry
|
||||
* per posting period (fromDate, toDate, interestAmount) -- so staff/customers can see exactly how much
|
||||
* interest is projected to post each month instead of a flat rate/12 approximation. Null for every other
|
||||
* FD type (see FixedDepositAccountsApiResource#retrieveOne).
|
||||
*/
|
||||
private java.util.List<java.util.Map<String, Object>> projectedInterestSchedule;
|
||||
private BigDecimal totalProjectedInterest;
|
||||
private BigDecimal totalValueAtMaturity;
|
||||
|
||||
public void setProjectedInterestSchedule(final java.util.List<java.util.Map<String, Object>> projectedInterestSchedule) {
|
||||
this.projectedInterestSchedule = projectedInterestSchedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-computed sum of projectedInterestSchedule, and principal + that sum -- the same "what does the
|
||||
* customer end up with overall" total the account view shows, computed once here instead of the
|
||||
* frontend re-summing the schedule array itself, so there is a single source of truth (matching how
|
||||
* maturityAmount itself already works).
|
||||
*/
|
||||
public void setProjectedInterestTotals(final BigDecimal totalProjectedInterest, final BigDecimal totalValueAtMaturity) {
|
||||
this.totalProjectedInterest = totalProjectedInterest;
|
||||
this.totalValueAtMaturity = totalValueAtMaturity;
|
||||
}
|
||||
|
||||
// CBS account-level fields (stored per-account in m_savings_account)
|
||||
private String accountStatus;
|
||||
private String modeOfOperation;
|
||||
|
||||
+13
@@ -340,6 +340,19 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
|
||||
this.onAccountClosureType = onClosureType.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies the three standing-instruction fields that make an FD Special account pay interest out
|
||||
* monthly and transfer the principal automatically at maturity, overriding whatever a modify-application
|
||||
* request just changed them to. Called by {@code FixedDepositAccount#modifyApplication} for SPECIAL-type
|
||||
* accounts, since {@link #update(JsonCommand, DataValidatorBuilder)} otherwise lets any of these three be
|
||||
* unset -- including by mere omission of maturityInstructionId/transferToSavingsId from the request body.
|
||||
*/
|
||||
public void forceMonthlyPayoutToLinkedSavings(final Long transferToSavingsId) {
|
||||
this.transferInterestToLinkedAccount = true;
|
||||
this.transferToSavingsAccountId = transferToSavingsId;
|
||||
this.onAccountClosureType = DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the standing instructions -- maturity instruction, its payout target and the interest-transfer flag -- from
|
||||
* the term being renewed.
|
||||
|
||||
+16
@@ -36,6 +36,22 @@ public class SavingsAccountTransactionComparator implements Comparator<SavingsAc
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
// For same date and same createdDate: interest postings must always be processed
|
||||
// before withdrawals/debits. This is critical for Special FD (Non-Cumulative)
|
||||
// accounts where the interest-transfer sweep withdrawal carries the same
|
||||
// transaction_date as the interest posting it pays out. When the original posting
|
||||
// is reversed and a new one is created the new posting gets a higher ID than the
|
||||
// old withdrawal. Without this tiebreaker the comparator falls through to
|
||||
// ID-order, placing the withdrawal before the new posting and causing the running
|
||||
// balance to dip incorrectly (e.g. ₹99,901.39 instead of ₹100,000.00).
|
||||
boolean o1IsPosting = o1.isInterestPostingAndNotReversed();
|
||||
boolean o2IsPosting = o2.isInterestPostingAndNotReversed();
|
||||
if (o1IsPosting && !o2IsPosting) {
|
||||
return -1; // interest posting before withdrawal/debit
|
||||
}
|
||||
if (!o1IsPosting && o2IsPosting) {
|
||||
return 1; // withdrawal/debit after interest posting
|
||||
}
|
||||
if (o1.getId() != null && o2.getId() != null) {
|
||||
return o1.getId().compareTo(o2.getId());
|
||||
}
|
||||
|
||||
+4
@@ -19,6 +19,8 @@
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
@@ -43,6 +45,8 @@ public interface DepositAccountWritePlatformService {
|
||||
|
||||
CommandProcessingResult calculateInterest(Long savingsId, DepositAccountType depositAccountType);
|
||||
|
||||
List<Map<String, Object>> retrieveProjectedInterestSchedule(Long savingsId, DepositAccountType depositAccountType);
|
||||
|
||||
CommandProcessingResult postInterest(Long savingsId, DepositAccountType depositAccountType);
|
||||
|
||||
CommandProcessingResult undoFDTransaction(Long savingsId, Long transactionId, boolean allowAccountTransferModification);
|
||||
|
||||
Reference in New Issue
Block a user