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:
+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
|
||||
|
||||
Reference in New Issue
Block a user