Compare commits
2
Commits
main
..
2569e0f836
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2569e0f836 | ||
|
|
81b5850b62 |
+1
-1
@@ -154,7 +154,7 @@ gitVersioning.apply {
|
||||
}
|
||||
}
|
||||
rev {
|
||||
version = '1.15.0-SNAPSHOT'
|
||||
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -163,7 +163,6 @@ public class CommandSourceService {
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "RECURRINGDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
|
||||
+56
-9
@@ -95,6 +95,62 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentParty() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTPARTY";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/parties";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateInvestmentParty(final Long partyId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "INVESTMENTPARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/investment/parties/" + partyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteInvestmentParty(final Long partyId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "INVESTMENTPARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/investment/parties/" + partyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentScript() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTSCRIPT";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/scripts";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateInvestmentScript(final Long scriptId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "INVESTMENTSCRIPT";
|
||||
this.entityId = scriptId;
|
||||
this.href = "/investment/scripts/" + scriptId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentPurchase() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTPURCHASE";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/purchases";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteInvestmentPurchase(final Long purchaseId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "INVESTMENTPURCHASE";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createChequeStockLot() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "CHEQUESTOCKLOT";
|
||||
@@ -3157,15 +3213,6 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder extendTenureRecurringDepositAccount(final Long accountId) {
|
||||
this.actionName = "EXTENDTENURE";
|
||||
this.entityName = "RECURRINGDEPOSITACCOUNT";
|
||||
this.entityId = accountId;
|
||||
this.savingsId = accountId;
|
||||
this.href = "/recurringdepositaccounts/" + accountId + "?command=extendTenure";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder prematureCloseRecurringDepositAccount(final Long accountId) {
|
||||
this.actionName = "PREMATURECLOSE";
|
||||
this.entityName = "RECURRINGDEPOSITACCOUNT";
|
||||
|
||||
-2
@@ -75,8 +75,6 @@ public enum JobName {
|
||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
||||
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
|
||||
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
|
||||
; //
|
||||
|
||||
private final String name;
|
||||
|
||||
-10
@@ -432,10 +432,6 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add("prematureWithdrawalOption");
|
||||
recurringDepositRequestData.add("tenureFlexibility");
|
||||
recurringDepositRequestData.add("lienMarked");
|
||||
recurringDepositRequestData.add("lienPercentage");
|
||||
recurringDepositRequestData.add("missedInstallmentGraceDays");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyType");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
|
||||
recurringDepositRequestData.add("withdrawalFeeForTransfers");
|
||||
recurringDepositRequestData.add("enableLockinPeriod");
|
||||
recurringDepositRequestData.add("maturityInstructions");
|
||||
@@ -495,10 +491,6 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add("prematureWithdrawalOption");
|
||||
recurringDepositRequestData.add("tenureFlexibility");
|
||||
recurringDepositRequestData.add("lienMarked");
|
||||
recurringDepositRequestData.add("lienPercentage");
|
||||
recurringDepositRequestData.add("missedInstallmentGraceDays");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyType");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
|
||||
recurringDepositRequestData.add("maturityInstructions");
|
||||
recurringDepositRequestData.add("creditMaturityProceeds");
|
||||
recurringDepositRequestData.add("penaltyGracePeriodMissedInstallments");
|
||||
@@ -656,8 +648,6 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add(SavingsApiConstants.balanceSwapOptionParamName);
|
||||
recurringDepositRequestData.add(SavingsApiConstants.msmeParamName);
|
||||
recurringDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
||||
// Lien Marked: how much of this deposit may be given as loan, capped by its product's percentage.
|
||||
recurringDepositRequestData.add("lienPercentage");
|
||||
recurringDepositRequestData.add(isSeniorCitizenParamName);
|
||||
recurringDepositRequestData.add(seniorCitizenInterestRateParamName);
|
||||
return recurringDepositRequestData;
|
||||
|
||||
-14
@@ -234,20 +234,6 @@ public final class SavingsAccountData implements Serializable {
|
||||
return this.transferInterestToLinkedAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a recurring deposit opened under a product whose Interest Calculation Type is "Simple": its interest
|
||||
* never compounds. Transient because it is only read by the batch interest-posting engine.
|
||||
*/
|
||||
private transient boolean simpleInterest;
|
||||
|
||||
public void setSimpleInterest(final boolean simpleInterest) {
|
||||
this.simpleInterest = simpleInterest;
|
||||
}
|
||||
|
||||
public boolean isSimpleInterest() {
|
||||
return this.simpleInterest;
|
||||
}
|
||||
|
||||
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||
|
||||
-6
@@ -180,12 +180,6 @@ public final class SavingsHelper {
|
||||
immediateWithdrawalOfInterest);
|
||||
}
|
||||
|
||||
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
|
||||
LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest, final boolean simpleInterest) {
|
||||
return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil,
|
||||
immediateWithdrawalOfInterest, simpleInterest);
|
||||
}
|
||||
|
||||
public Collection<Long> fetchPostInterestTransactionIds(Long accountId) {
|
||||
return this.accountTransfersReadPlatformService.fetchPostInterestTransactionIds(accountId);
|
||||
}
|
||||
|
||||
+1
-35
@@ -40,18 +40,6 @@ public class CompoundInterestHelper {
|
||||
*/
|
||||
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
|
||||
LocalDate lockUntil, Boolean interestTransferEnabled) {
|
||||
return calculateInterestForAllPostingPeriods(currency, allPeriods, lockUntil, interestTransferEnabled, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param simpleInterest
|
||||
* - when true, earned interest is never added back to the balance it is earned on, so every period
|
||||
* earns on the principal alone (a recurring deposit whose product's Interest Calculation Type is
|
||||
* "Simple"). Compounding and posting periods still decide when interest is recognised and posted;
|
||||
* they just stop changing how much is earned.
|
||||
*/
|
||||
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
|
||||
LocalDate lockUntil, Boolean interestTransferEnabled, final boolean simpleInterest) {
|
||||
|
||||
// sum up the 'rounded' values that are posted each posting period
|
||||
Money interestEarned = Money.zero(currency);
|
||||
@@ -59,8 +47,7 @@ public class CompoundInterestHelper {
|
||||
// total interest earned in previous periods but not yet recognised
|
||||
BigDecimal compoundedInterest = BigDecimal.ZERO;
|
||||
BigDecimal unCompoundedInterest = BigDecimal.ZERO;
|
||||
final CompoundInterestValues compoundInterestValues = simpleInterest ? new SimpleInterestValues()
|
||||
: new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
|
||||
final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
|
||||
for (final PostingPeriod postingPeriod : allPeriods) {
|
||||
|
||||
final BigDecimal interestEarnedThisPeriod = postingPeriod.calculateInterest(compoundInterestValues);
|
||||
@@ -80,25 +67,4 @@ public class CompoundInterestHelper {
|
||||
|
||||
return interestEarned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest values that refuse to compound: whatever a compounding period hands over to be compounded is dropped,
|
||||
* so the balance interest is earned on never includes interest already earned.
|
||||
*/
|
||||
private static final class SimpleInterestValues extends CompoundInterestValues {
|
||||
|
||||
SimpleInterestValues() {
|
||||
super(BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCompoundedInterest(final BigDecimal interestToBeCompounded) {
|
||||
super.setCompoundedInterest(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSimpleInterest() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-5
@@ -50,9 +50,4 @@ public class CompoundInterestValues {
|
||||
this.uncompoundedInterest = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
/** True when earned interest must never earn interest itself - not across periods, and not within one either. */
|
||||
public boolean isSimpleInterest() {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-12
@@ -94,18 +94,6 @@ public final class DailyCompoundingPeriod implements CompoundingPeriod {
|
||||
return interestEarned;
|
||||
}
|
||||
|
||||
/** Interest on each day's balance alone, none of it added to the balance - for simple interest. */
|
||||
public BigDecimal calculateSimpleInterest(final BigDecimal interestRateAsFraction, final long daysInYear,
|
||||
final BigDecimal minBalanceForInterestCalculation, final BigDecimal overdraftInterestRateAsFraction,
|
||||
final BigDecimal minOverdraftForInterestCalculation) {
|
||||
BigDecimal interestEarned = BigDecimal.ZERO;
|
||||
for (final EndOfDayBalance balance : this.endOfDayBalances) {
|
||||
interestEarned = interestEarned.add(balance.calculateInterestOnBalance(BigDecimal.ZERO, interestRateAsFraction, daysInYear,
|
||||
minBalanceForInterestCalculation, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation));
|
||||
}
|
||||
return interestEarned;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalDateInterval getPeriodInterval() {
|
||||
return this.periodInterval;
|
||||
|
||||
+4
-11
@@ -301,17 +301,10 @@ public final class PostingPeriod {
|
||||
// to be applied to the balanced for interest calculation
|
||||
for (final CompoundingPeriod compoundingPeriod : this.compoundingPeriods) {
|
||||
|
||||
// A daily compounding period compounds day by day inside itself, so simple interest has to bypass it - zeroing
|
||||
// what is carried between periods alone still left a month's interest earning interest within the month.
|
||||
final BigDecimal interestUnrounded = compoundInterestValues.isSimpleInterest()
|
||||
&& compoundingPeriod instanceof DailyCompoundingPeriod dailyPeriod
|
||||
? dailyPeriod.calculateSimpleInterest(this.interestRateAsFraction, this.daysInYear,
|
||||
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
|
||||
this.minOverdraftForInterestCalculation.getAmount())
|
||||
: compoundingPeriod.calculateInterest(this.interestCompoundingType, this.interestCalculationType,
|
||||
compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction, this.daysInYear,
|
||||
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
|
||||
this.minOverdraftForInterestCalculation.getAmount());
|
||||
final BigDecimal interestUnrounded = compoundingPeriod.calculateInterest(this.interestCompoundingType,
|
||||
this.interestCalculationType, compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
|
||||
this.daysInYear, this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
|
||||
this.minOverdraftForInterestCalculation.getAmount());
|
||||
BigDecimal unCompoundedInterest = compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
|
||||
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
|
||||
LocalDate compoundingPeriodEndDate = compoundingPeriod.getPeriodInterval().endDate();
|
||||
|
||||
+2
-41
@@ -18,29 +18,20 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.documentmanagement.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService {
|
||||
|
||||
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
|
||||
private static final int MAX_CAUSE_DEPTH = 10;
|
||||
|
||||
private final ContentStoreService storeService;
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentMapper documentMapper;
|
||||
@@ -61,39 +52,9 @@ public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformServ
|
||||
public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) {
|
||||
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
|
||||
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
|
||||
try {
|
||||
final var is = storeService.download(doc.getLocation());
|
||||
return documentMapper.map(doc, is);
|
||||
} catch (final AbstractPlatformDomainRuleException e) {
|
||||
throw e;
|
||||
} catch (final Exception e) {
|
||||
if (isMissingFromContentStore(e)) {
|
||||
// The m_document row survives but the file it points at does not — the usual cause is
|
||||
// a database copied between environments without its file store (see the identical
|
||||
// guard in ImageReadPlatformServiceImpl). For the caller that is simply "this document
|
||||
// has no content", not a server fault, so report it the same way as a missing document
|
||||
// instead of letting a raw ContentStoreException surface as a 500.
|
||||
log.warn("Document {} for {} {} is recorded but missing from the content store; treating it as not found", documentId,
|
||||
entityType, entityId);
|
||||
throw new DocumentNotFoundException(entityType, entityId, documentId);
|
||||
}
|
||||
throw new DocumentInvalidRequestException(e);
|
||||
}
|
||||
}
|
||||
final var is = storeService.download(doc.getLocation());
|
||||
|
||||
/**
|
||||
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
|
||||
* way up (the file store rethrows as ContentStoreException).
|
||||
*/
|
||||
private boolean isMissingFromContentStore(final Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
return documentMapper.map(doc, is);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+3
-28
@@ -650,7 +650,7 @@ public class AccountingProcessorHelper {
|
||||
*/
|
||||
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
|
||||
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
|
||||
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal, final boolean payableCarriesAccrual) {
|
||||
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
|
||||
|
||||
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
||||
@@ -665,14 +665,10 @@ public class AccountingProcessorHelper {
|
||||
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Recurring Deposit Interest Payable",
|
||||
"Interest Payable", "Interest Payable Account")))
|
||||
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
|
||||
.orElse(controlAccount);
|
||||
// The RD product screen saves its paid account as "Recurring Deposit Interest Paid"; without that name here an
|
||||
// RD's interest skipped the paid leg even when the product had one mapped.
|
||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Recurring Deposit Interest Paid", "Interest Paid",
|
||||
"Interest Paid Account"))
|
||||
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
|
||||
.orElse(payableAccount);
|
||||
|
||||
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
|
||||
@@ -710,19 +706,6 @@ public class AccountingProcessorHelper {
|
||||
// debit balance would come back negative and every posting would raise a shortfall larger
|
||||
// than the interest itself.
|
||||
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
|
||||
if (!payableCarriesAccrual) {
|
||||
// Cash basis: nothing ever accrues into the payable, so whatever already sits on it - an opening balance
|
||||
// brought over at migration, or another product's interest sharing the GL - is not this interest. Reading
|
||||
// it as "already accrued" skipped the expense and drew that balance down instead, leaving the cost off the
|
||||
// P&L. The whole amount is charged and raised, and a reversal or clawback takes it back the same way.
|
||||
if (payableIsLiability) {
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
|
||||
amount, isReversal);
|
||||
}
|
||||
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId,
|
||||
transactionDate, amount, isReversal);
|
||||
return;
|
||||
}
|
||||
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
|
||||
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
|
||||
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
|
||||
@@ -735,14 +718,6 @@ public class AccountingProcessorHelper {
|
||||
shortfall, false);
|
||||
}
|
||||
|
||||
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId, transactionDate,
|
||||
amount, isReversal);
|
||||
}
|
||||
|
||||
/** Stages 2 and 3: out of the payable, through the paid account when the product names one, into the member. */
|
||||
private void postPayableToMember(final Office office, final String currencyCode, final GLAccount payableAccount,
|
||||
final GLAccount paidAccount, final GLAccount controlAccount, final Long savingsId, final String transactionId,
|
||||
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
|
||||
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
|
||||
if (hasSeparatePaidAccount) {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
|
||||
// payable-to-control pair that used to be here, which skipped interest-paid
|
||||
// entirely and left the expense off the P&L.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, true);
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||
// that names neither.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, false);
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -294,14 +294,8 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
||||
SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), savingProductId,
|
||||
CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue());
|
||||
|
||||
// FD and RD products switch dormancy on with "dormancyAlert" (DepositProductAssembler derives the product's
|
||||
// tracking flag from it) and never send isDormancyTrackingActive, so reading only that dropped the escheat
|
||||
// account the screen had asked for - the product then tracked dormancy with no escheat account mapped.
|
||||
final Boolean isDormancyTrackingActive = this.fromApiJsonHelper.extractBooleanNamed(isDormancyTrackingActiveParamName, element);
|
||||
final Boolean dormancyAlert = this.fromApiJsonHelper.parameterExists("dormancyAlert", element)
|
||||
? this.fromApiJsonHelper.extractBooleanNamed("dormancyAlert", element)
|
||||
: null;
|
||||
if (Boolean.TRUE.equals(isDormancyTrackingActive) || Boolean.TRUE.equals(dormancyAlert)) {
|
||||
if (null != isDormancyTrackingActive && isDormancyTrackingActive) {
|
||||
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
|
||||
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingProductId,
|
||||
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
||||
|
||||
+10
-44
@@ -27,7 +27,6 @@ import static org.apache.fineract.portfolio.account.api.AccountTransfersApiConst
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@@ -45,7 +44,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
||||
@@ -76,13 +74,11 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@@ -106,7 +102,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||
private final AccountingProcessorHelper accountingProcessorHelper;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -152,7 +147,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
||||
throw new DifferentCurrenciesException(fromSavingsAccount.getCurrency().getCode(),
|
||||
@@ -208,7 +202,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
|
||||
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
|
||||
@@ -414,15 +407,11 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
||||
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
||||
toSavingsAccount, withdrawal, deposit);
|
||||
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
|
||||
transferTransactionId = accountTransferDetails.getId();
|
||||
// FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted.
|
||||
this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit,
|
||||
AccountTransferType.fromInt(accountTransferDTO.getTransferType()));
|
||||
|
||||
} else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
|
||||
|
||||
@@ -460,7 +449,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
||||
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(accountTransferDTO, fromLoanAccount,
|
||||
toSavingsAccount, deposit, loanTransaction);
|
||||
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
|
||||
@@ -495,7 +483,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
|
||||
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
|
||||
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
if (accountTransferDetails == null) {
|
||||
accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
|
||||
@@ -657,26 +644,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
return accountTransferDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Money transferred into a recurring deposit is an installment payment, exactly like a counter deposit through
|
||||
* DepositAccountDomainServiceJpa#handleRDDeposit. The generic deposit above only credits the balance, so without
|
||||
* this the deposit's schedule kept showing the installment as unpaid and overdue, and its projected maturity
|
||||
* amount never moved - for manual transfers, standing instructions and RD Auto Debit alike. Any other destination
|
||||
* is left untouched.
|
||||
*/
|
||||
private void applyToRecurringDepositSchedule(final SavingsAccount toSavingsAccount, final SavingsAccountTransaction deposit) {
|
||||
if (!(toSavingsAccount instanceof RecurringDepositAccount recurringDeposit)) {
|
||||
return;
|
||||
}
|
||||
final boolean isPreMatureClosure = false;
|
||||
recurringDeposit.updateDepositAmount(deposit.getAmount());
|
||||
recurringDeposit.handleScheduleInstallments(deposit);
|
||||
recurringDeposit.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure,
|
||||
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
|
||||
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
|
||||
recurringDeposit.updateOverduePayments(DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
private boolean isLoanToSavingsAccountTransfer(final PortfolioAccountType fromAccountType, final PortfolioAccountType toAccountType) {
|
||||
return fromAccountType.isLoanAccount() && toAccountType.isSavingsAccount();
|
||||
}
|
||||
@@ -736,8 +703,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
currentAccountRepository.save(account);
|
||||
if (account.getProductId() != null && txn.getId() != null) {
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
account.getId(), currentTxnId, txnDate, amount);
|
||||
}
|
||||
return txn;
|
||||
@@ -748,7 +715,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
@@ -768,7 +735,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -777,7 +744,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Deposit amount into a current account and save the transaction.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
@@ -790,7 +757,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -836,13 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
if (isWithdrawal) {
|
||||
// DR: current account GL CR: counterpart (savings/current) GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||
currentTxnId, txnDate, amount);
|
||||
} else {
|
||||
// DR: counterpart (savings/current) GL CR: current account GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
currentTxnId, txnDate, amount);
|
||||
}
|
||||
}
|
||||
@@ -885,7 +852,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||
transactionAmount, paymentDetail, true, true, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
|
||||
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
|
||||
|
||||
+2
-5
@@ -61,7 +61,6 @@ import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -98,14 +97,12 @@ public class AccountConfiguration {
|
||||
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
||||
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
||||
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard,
|
||||
SavingsInterestTdsService savingsInterestTdsService) {
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
|
||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard,
|
||||
savingsInterestTdsService);
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+5
-54
@@ -122,26 +122,11 @@ public class BranchDayRegisterApiResource {
|
||||
result.put("officeId", officeId);
|
||||
result.put("canOperate", canOperateDay(currentUser));
|
||||
result.put("days", mapDays(rows));
|
||||
result.put("today", DateUtils.getBusinessLocalDate().toString());
|
||||
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
|
||||
result.put("openDay", findOpenDay(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/today ── the bank's "today" ─────────────────────────
|
||||
|
||||
/**
|
||||
* The business date while enable-business-date is on, the tenant's own date while it is off.
|
||||
* Every screen that defaults a date to "today" reads this, rather than the browser clock or the
|
||||
* business-date API (which returns the stored date even when the feature is off). Open to any
|
||||
* signed-in user — cashiers and clerks cannot read the configuration that says which applies.
|
||||
*/
|
||||
@GET
|
||||
@Path("today")
|
||||
public String today() {
|
||||
this.context.authenticatedUser();
|
||||
return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString()));
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -154,7 +139,7 @@ public class BranchDayRegisterApiResource {
|
||||
public String missed(@QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
||||
@@ -268,7 +253,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
DateUtils.getBusinessLocalDate());
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
@@ -462,7 +447,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
DateUtils.getBusinessLocalDate());
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
|
||||
@@ -789,13 +774,11 @@ public class BranchDayRegisterApiResource {
|
||||
final Long registerId = ((Number) found.get(0).get("id")).longValue();
|
||||
saveDenominationDetail(registerId, "CLOSING", denominations);
|
||||
snapshotVaultDenominations(officeId, registerId, businessDate);
|
||||
final int depositsClosed = closeMaturedZeroBalanceRecurringDeposits(officeId, businessDate, currentUser);
|
||||
|
||||
final Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("officeId", officeId);
|
||||
res.put("businessDate", businessDate.toString());
|
||||
res.put("status", "CLOSED");
|
||||
res.put("zeroBalanceDepositsClosed", depositsClosed);
|
||||
res.put("closingCash", closingCash);
|
||||
res.put("bookCash", bookCash);
|
||||
res.put("cashDifference", difference);
|
||||
@@ -803,38 +786,6 @@ public class BranchDayRegisterApiResource {
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Account Closed with Zero balance in Day Closing" on a recurring deposit product: at the branch's day close, each
|
||||
* of its recurring deposits that has MATURED with nothing left in it - no balance, and no loan holding a lien on it
|
||||
* - is marked Closed on that business date, so it no longer waits in the matured list.
|
||||
*
|
||||
* Only matured deposits: a running deposit sits at zero between opening and its first installment, and must never
|
||||
* be closed for that. Nothing is paid out (there is nothing to pay), so no transaction or journal entry is made.
|
||||
*
|
||||
* @return how many deposits were closed
|
||||
*/
|
||||
private int closeMaturedZeroBalanceRecurringDeposits(final Long officeId, final LocalDate businessDate, final AppUser currentUser) {
|
||||
final int closed = jdbcTemplate.update("""
|
||||
UPDATE m_savings_account sa
|
||||
SET status_enum = 600, closedon_date = ?, closedon_userid = ?
|
||||
FROM m_savings_product sp, m_client c
|
||||
WHERE sp.id = sa.product_id AND c.id = sa.client_id AND c.office_id = ?
|
||||
AND sa.deposit_type_enum = 300 AND sa.status_enum = 800
|
||||
AND COALESCE(sp.account_closed_zero_balance_in_day_closing, false) = true
|
||||
AND COALESCE(sa.account_balance_derived, 0) = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM m_portfolio_account_associations paa
|
||||
JOIN m_guarantor_funding_details gfd ON gfd.account_associations_id = paa.id
|
||||
JOIN m_loan l ON l.id = paa.loan_account_id
|
||||
WHERE paa.linked_savings_account_id = sa.id AND gfd.status_enum = 100
|
||||
AND l.loan_status_id IN (100, 200, 300)
|
||||
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0)
|
||||
""", Date.valueOf(businessDate), currentUser.getId(), officeId);
|
||||
if (closed > 0) {
|
||||
log.info("Day close {}: office {} closed {} matured recurring deposit(s) with zero balance", businessDate, officeId, closed);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ────
|
||||
|
||||
/**
|
||||
@@ -850,7 +801,7 @@ public class BranchDayRegisterApiResource {
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank())
|
||||
? LocalDate.parse(businessDateParam)
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
|
||||
+9
-10
@@ -727,7 +727,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long savingsAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -761,7 +761,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -790,7 +790,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -844,7 +844,7 @@ public class CashierDepositRequestApiResource {
|
||||
return;
|
||||
}
|
||||
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -867,7 +867,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long currentAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -976,7 +976,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Object paymentTypeId, final String txnDateStr, final String note,
|
||||
final boolean isDeposit) {
|
||||
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
// Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra)
|
||||
@@ -1073,9 +1073,8 @@ public class CashierDepositRequestApiResource {
|
||||
value, count, BigDecimal.valueOf((long) value * count),
|
||||
// Dated on the request's own date, which is what the branch day's book figure reads.
|
||||
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
|
||||
// drawer on another and leave both days' cash wrong. No date falls back to the
|
||||
// business date for the same reason.
|
||||
Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()),
|
||||
// drawer on another and leave both days' cash wrong.
|
||||
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
|
||||
cashierId, cashierUsername, paymentTypeName
|
||||
);
|
||||
}
|
||||
@@ -1180,7 +1179,7 @@ public class CashierDepositRequestApiResource {
|
||||
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -83,7 +83,7 @@ public class CashReportApiResource {
|
||||
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
|
||||
@QueryParam("status") final String status) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
@@ -470,7 +470,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("closing")
|
||||
public String closing(@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
|
||||
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
||||
@@ -938,7 +938,7 @@ public class CashReportApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
final String today = DateUtils.getBusinessLocalDate().toString();
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
// Find the earliest transfer date before today for which no settlement exists on or after that date
|
||||
final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled "
|
||||
@@ -1001,7 +1001,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-position")
|
||||
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
// CASHIER role: always scope to their own position only
|
||||
final Long scopedId = cashierRoleHelper.getCashierIdForFiltering();
|
||||
@@ -1032,7 +1032,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("cashier-position/me")
|
||||
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
Long appUserId;
|
||||
try {
|
||||
appUserId = context.authenticatedUser().getId();
|
||||
@@ -1070,7 +1070,7 @@ public class CashReportApiResource {
|
||||
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
|
||||
@QueryParam("asOfDate") final String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
|
||||
: DateUtils.getBusinessLocalDate().toString();
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
if (tellerId == null) {
|
||||
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
|
||||
@@ -1092,7 +1092,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-morning")
|
||||
public String cashierMorning(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
if (cashierId == null) {
|
||||
Map<String, Object> empty = new LinkedHashMap<>();
|
||||
empty.put("cashierId", null);
|
||||
@@ -1304,7 +1304,7 @@ public class CashReportApiResource {
|
||||
@QueryParam("offset") Integer offset,
|
||||
@QueryParam("limit") Integer limit) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final int resolvedLimit = (limit != null && limit > 0) ? limit : 20;
|
||||
final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0;
|
||||
|
||||
|
||||
+3
-3
@@ -89,7 +89,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
|
||||
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate();
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
|
||||
? json.get("fromParty").getAsString() : null;
|
||||
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
|
||||
@@ -226,7 +226,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
glDispatchService.allocateCashToCashier(glTellerId,
|
||||
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -247,7 +247,7 @@ public class CashSettlementApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required",
|
||||
"cashierId query param is required.");
|
||||
}
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
|
||||
Integer.class, cashierId, settlementDate);
|
||||
|
||||
+3
-3
@@ -146,7 +146,7 @@ public class CashTransferApiResource {
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total,
|
||||
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
note != null ? note : "");
|
||||
@@ -206,7 +206,7 @@ public class CashTransferApiResource {
|
||||
}
|
||||
glDispatchService.settleCashFromCashier(glTellerId,
|
||||
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -291,7 +291,7 @@ public class CashTransferApiResource {
|
||||
"Vault/teller " + fromTellerId + " was not found.");
|
||||
}
|
||||
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.query("""
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource {
|
||||
final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank())
|
||||
? LocalDate.parse(txnDateStr)
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) {
|
||||
return "{\"result\":\"skipped\"}";
|
||||
|
||||
+5
-5
@@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Long resolvedClientId = toLong(dep.get("client_id"));
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
|
||||
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String receiptNo = nextReceiptNo();
|
||||
// What the cashier actually typed at save time, when they typed anything -- that is what
|
||||
// the Apply Share screen's Description column exists to show. Falls back to a plain
|
||||
@@ -927,7 +927,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String voucherNo = (String) req.get("voucher_no");
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1006,7 +1006,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
|
||||
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
// What the cashier actually typed at save time, when they typed anything -- same preference
|
||||
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
|
||||
@@ -1093,7 +1093,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1338,7 +1338,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
private LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return DateUtils.getBusinessLocalDate();
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
|
||||
-30
@@ -176,7 +176,6 @@ public class ClerkDisbursementRequestApiResource {
|
||||
// A GL leg carries no savingsAccountId and the guard ignores it.
|
||||
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
|
||||
// m_current_account -- checking it as a savings id would test some unrelated savings account.
|
||||
rejectRecurringDepositLeg(entry, entryAccountType);
|
||||
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
||||
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
|
||||
: KycRestrictionGuard.Operation.CREDIT;
|
||||
@@ -487,8 +486,6 @@ public class ClerkDisbursementRequestApiResource {
|
||||
throw apiError(400, "Account ID missing for entry: " + acNo
|
||||
+ ". Clerk must re-submit with account details loaded.");
|
||||
}
|
||||
// Also refuses requests raised before RD legs were blocked at submit.
|
||||
rejectRecurringDepositLeg(entry, entryType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,7 +932,6 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (amt.compareTo(BigDecimal.ZERO) <= 0)
|
||||
throw apiError(400, "Amount must be greater than zero. Account: "
|
||||
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
|
||||
rejectRecurringDepositLeg(e, acTyp);
|
||||
drAccountIds.add(e.get("savingsAccountId").getAsLong());
|
||||
}
|
||||
for (final JsonObject e : crEntries) {
|
||||
@@ -948,7 +944,6 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (amt.compareTo(BigDecimal.ZERO) <= 0)
|
||||
throw apiError(400, "Amount must be greater than zero. Account: "
|
||||
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
|
||||
rejectRecurringDepositLeg(e, acTyp);
|
||||
final Long crId = e.get("savingsAccountId").getAsLong();
|
||||
if (drAccountIds.contains(crId)) throw apiError(400,
|
||||
"Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId)
|
||||
@@ -1198,31 +1193,6 @@ public class ClerkDisbursementRequestApiResource {
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** m_savings_account.deposit_type_enum of a recurring deposit (Fineract's DepositAccountType.RECURRING_DEPOSIT). */
|
||||
private static final int DEPOSIT_TYPE_RECURRING = 300;
|
||||
|
||||
/**
|
||||
* A recurring deposit cannot be a leg of any clerk request. Every request type here posts straight into the
|
||||
* account with a direct insert, which never reaches the RD's installment schedule: the installments stay unpaid,
|
||||
* Auto Debit collects them a second time and the maturity amount is computed on the wrong schedule. RD
|
||||
* installments come in through the cashier's Receipt (the RD deposit command) or the RD's own Auto Debit.
|
||||
* Looked up by id, not trusted from the entry's label; a CURRENT leg's id belongs to m_current_account.
|
||||
*/
|
||||
private void rejectRecurringDepositLeg(final JsonObject entry, final String accountType) {
|
||||
if ("GL".equals(accountType) || "CURRENT".equals(accountType) || !entry.has("savingsAccountId")
|
||||
|| entry.get("savingsAccountId").isJsonNull()) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT account_no FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
|
||||
entry.get("savingsAccountId").getAsLong(), DEPOSIT_TYPE_RECURRING);
|
||||
if (!rows.isEmpty()) {
|
||||
throw apiError(422, "Recurring deposit " + rows.get(0).get("account_no")
|
||||
+ " cannot be used in a clerk Disbursement, Collection, Fund Transfer or Bank Transfer."
|
||||
+ " Pay RD installments through a cashier Receipt or Auto Debit.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
|
||||
|
||||
+14
-113
@@ -390,101 +390,6 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
public String validate(@PathParam("id") final Long id) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(record);
|
||||
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
|
||||
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
|
||||
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the clerk see the same checklist the approver will see, before the request is even
|
||||
* submitted — so a request that is going to fail a check (a missing GL mapping, an unverified
|
||||
* KYC, an open AML alert) is caught while someone can still fix it, not after a manager has
|
||||
* already spent time reviewing it. Takes the same fields {@link #submit(String)} does and
|
||||
* applies the identical Sundry-downgrade rule, so what this previews is exactly what would be
|
||||
* stored and exactly what {@link #validate(Long)} would later report on the saved request —
|
||||
* never a second, independently-drifting copy of the rules.
|
||||
*
|
||||
* <p>
|
||||
* The one check {@link #validate(Long)} runs that this cannot — "Maker and Checker are
|
||||
* different users" — has no real answer yet: the clerk previewing this *is* the maker, and no
|
||||
* checker has been chosen. It is reported here as a fixed reminder rather than silently
|
||||
* dropped, so the clerk sees why the two lists differ by exactly that one row, and knows the
|
||||
* rule is still enforced later, at {@code approve()}.
|
||||
*/
|
||||
@POST
|
||||
@Path("/preview-validate")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String previewValidate(final String body) {
|
||||
context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
|
||||
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
|
||||
if (shareAccountId == null) {
|
||||
throw apiError(400, "Share account id is required");
|
||||
}
|
||||
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
|
||||
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
|
||||
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
|
||||
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
|
||||
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
|
||||
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
|
||||
: settlementModeRaw.trim().toUpperCase();
|
||||
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
|
||||
|
||||
// Mirrors submit()'s own downgrade: naming the product's own Share Sundry GL as the
|
||||
// destination means "stop here", not a real GL settlement — see submit() for the rationale.
|
||||
if ("GL".equals(settlementMode) && settlementGlAccountId != null) {
|
||||
final Long productId = jdbcTemplate.queryForList("SELECT product_id FROM m_share_account WHERE id = ?", shareAccountId)
|
||||
.stream().findFirst().map(r -> ((Number) r.get("product_id")).longValue()).orElse(null);
|
||||
if (productId != null) {
|
||||
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
|
||||
if (glSundry != null && glSundry.equals(settlementGlAccountId)) {
|
||||
settlementMode = "NONE";
|
||||
settlementGlAccountId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, Object> draftRecord = new java.util.HashMap<>();
|
||||
draftRecord.put("resolution_number", resolutionNumber);
|
||||
draftRecord.put("meeting_date", meetingDate);
|
||||
draftRecord.put("share_account_id", shareAccountId);
|
||||
draftRecord.put("requested_shares", requestedShares != null ? requestedShares : 0L);
|
||||
draftRecord.put("settlement_mode", settlementMode);
|
||||
draftRecord.put("settlement_gl_account_id", settlementGlAccountId);
|
||||
draftRecord.put("settlement_payment_type_id", settlementPaymentTypeId);
|
||||
|
||||
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(draftRecord);
|
||||
checks.add(checkResult("Maker and Checker are different users", true,
|
||||
"Verified automatically when a different manager approves"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full checklist a checker sees before approving a share withdrawal — share balance and
|
||||
* status, loan/tenure/guarantor exposure, settlement destination and payment type, and client
|
||||
* KYC/risk/AML standing. Shared verbatim between {@link #validate(Long)} (an already-submitted
|
||||
* request) and {@link #previewValidate(String)} (a draft that does not exist yet), so the two
|
||||
* can never silently diverge. Reads {@code record} by the same string keys both callers
|
||||
* populate: resolution_number, meeting_date, share_account_id, requested_shares,
|
||||
* settlement_mode, settlement_gl_account_id, settlement_payment_type_id.
|
||||
*/
|
||||
private List<Map<String, Object>> buildShareWithdrawalChecks(final Map<?, ?> record) {
|
||||
final List<Map<String, Object>> checks = new ArrayList<>();
|
||||
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
@@ -546,18 +451,8 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// not-applicable rather than failed for a GL request — a share account with no linked
|
||||
// savings is a perfectly valid GL-settlement case.
|
||||
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
|
||||
// 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who
|
||||
// names the product's own Share Sundry GL as the destination is saying "stop here", so the
|
||||
// request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry
|
||||
// and nothing further. Like a GL settlement it never reaches a savings account, so the
|
||||
// savings-side checks below do not apply to it either — running them reports a missing fund
|
||||
// source mapping that no posting in this mode is ever going to read.
|
||||
final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode"));
|
||||
final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement;
|
||||
checks.add(noSavingsLeg
|
||||
? checkResult("Linked savings account is Active", true, sundryOnlySettlement
|
||||
? "Not applicable — the redemption rests in Share Sundry"
|
||||
: "Not applicable — settlement is a GL book-transfer")
|
||||
checks.add(glSettlementMode
|
||||
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
|
||||
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||
savingsAccountId == null ? "No linked savings account configured"
|
||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||
@@ -646,9 +541,6 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
final boolean settlementGlPresent = glAccountIdObj != null;
|
||||
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
|
||||
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
|
||||
} else if (sundryOnlySettlement) {
|
||||
checks.add(checkResult("Settlement destination recorded", true,
|
||||
"Share Sundry — the redemption stops there, so no fund source mapping is read"));
|
||||
} else {
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
@@ -687,7 +579,16 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
|
||||
}
|
||||
|
||||
return checks;
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
|
||||
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
|
||||
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
|
||||
@@ -1091,8 +992,8 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
|
||||
final BigDecimal unitPrice = jdbcTemplate
|
||||
.queryForList("""
|
||||
SELECT unit_price FROM m_share_account_transactions
|
||||
WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL
|
||||
SELECT unit_price FROM m_share_account_transaction
|
||||
WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", BigDecimal.class, shareAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.api;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Constants for the Treasury Investment masters (Bank / Party Master and Script Master), modelled on the bank's existing
|
||||
* CBS Investment screens.
|
||||
*/
|
||||
public final class InvestmentApiConstants {
|
||||
|
||||
private InvestmentApiConstants() {}
|
||||
|
||||
public static final String ENTITY_PARTY = "INVESTMENTPARTY";
|
||||
public static final String ENTITY_SCRIPT = "INVESTMENTSCRIPT";
|
||||
public static final String RESOURCE_PARTY = "investmentparty";
|
||||
public static final String RESOURCE_SCRIPT = "investmentscript";
|
||||
|
||||
/** Instrument ("Bond Type") an investment party / script belongs to. */
|
||||
public static final Set<String> BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "FD", "SHARE");
|
||||
|
||||
/** Bond types that carry coupon terms (ISIN, interest rate, issue date are mandatory for these). */
|
||||
public static final Set<String> DEBT_BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND");
|
||||
|
||||
/** SLR classification on the Script Master ("GOI" = Central Government). */
|
||||
public static final Set<String> SLR_TYPES = Set.of("GOI", "STATE_GOVT", "TREASURY", "NON_SLR");
|
||||
|
||||
/** Script Code prefix per bond type, e.g. G00001 for the first Govt Security. */
|
||||
public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND",
|
||||
"M", "FD", "F", "SHARE", "S");
|
||||
|
||||
public static final String PARTY_CODE_PREFIX = "PTY-";
|
||||
|
||||
// Common parameters
|
||||
public static final String BOND_TYPE = "bondType";
|
||||
public static final String GL_ACCOUNT_ID = "glAccountId";
|
||||
public static final String ADDRESS1 = "address1";
|
||||
public static final String ADDRESS2 = "address2";
|
||||
public static final String ADDRESS3 = "address3";
|
||||
public static final String CITY = "city";
|
||||
public static final String PINCODE = "pincode";
|
||||
public static final String PHONE = "phone";
|
||||
public static final String FAX = "fax";
|
||||
public static final String ACTIVE = "active";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
// Party parameters
|
||||
public static final String PARTY_NAME = "partyName";
|
||||
public static final String BANK_TYPE = "bankType";
|
||||
public static final String RBI_CODE = "rbiCode";
|
||||
public static final String EXPOSURE_LIMIT = "limit";
|
||||
public static final String LEI_NO = "leiNo";
|
||||
public static final String CP_BANK_NAME = "cpBankName";
|
||||
public static final String CP_BRANCH_NAME = "cpBranchName";
|
||||
public static final String CP_ACCOUNT_NO = "cpAccountNo";
|
||||
public static final String CP_IFSC = "cpIfsc";
|
||||
|
||||
// Script parameters
|
||||
public static final String SLR_TYPE = "slrType";
|
||||
public static final String SCRIPT_DESC = "scriptDesc";
|
||||
public static final String ISIN = "isin";
|
||||
public static final String FACE_VALUE_PER_UNIT = "faceValuePerUnit";
|
||||
public static final String INT_RATE = "intRate";
|
||||
public static final String ISSUE_DATE = "issueDate";
|
||||
public static final String MATURITY_DATE = "maturityDate";
|
||||
public static final String COMPANY_NAME = "companyName";
|
||||
public static final String CORP_CODE = "corpCode";
|
||||
|
||||
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
|
||||
BANK_TYPE, GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
|
||||
LOCALE);
|
||||
|
||||
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT,
|
||||
INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
|
||||
ACTIVE, LOCALE, DATE_FORMAT);
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/parties")
|
||||
@Component
|
||||
@Tag(name = "Investment Bank / Party Master", description = "Counterparties an investment is bought from or placed with.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentPartyApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentMasterReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List investment parties")
|
||||
public List<InvestmentPartyData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
|
||||
return this.readPlatformService.retrieveAllParties();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{partyId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve an investment party")
|
||||
public InvestmentPartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
|
||||
return this.readPlatformService.retrieveParty(partyId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create an investment party")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentParty().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{partyId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update an investment party")
|
||||
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentParty(partyId).withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{partyId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Drop an investment party")
|
||||
public CommandProcessingResult delete(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentParty(partyId).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.api;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Constants for the Investment Purchase Entry (a deal that creates a holding). */
|
||||
public final class InvestmentPurchaseApiConstants {
|
||||
|
||||
private InvestmentPurchaseApiConstants() {}
|
||||
|
||||
public static final String ENTITY = "INVESTMENTPURCHASE";
|
||||
public static final String RESOURCE = "investmentpurchase";
|
||||
|
||||
public static final Set<String> SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2");
|
||||
public static final Set<String> CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)");
|
||||
|
||||
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||
|
||||
public static final String BOND_TYPE = "bondType";
|
||||
public static final String SCRIPT_ID = "scriptId";
|
||||
public static final String PARTY_ID = "partyId";
|
||||
public static final String DEAL_DATE = "dealDate";
|
||||
public static final String DEAL_TIME = "dealTime";
|
||||
public static final String SETTLEMENT_TYPE = "settlementType";
|
||||
public static final String FACE_VALUE = "faceValue";
|
||||
public static final String PURCHASE_RATE = "purchaseRate";
|
||||
public static final String CONSIDER_AMT = "considerAmt";
|
||||
public static final String NAV = "nav";
|
||||
public static final String STAMP_DUTY = "stampDuty";
|
||||
public static final String BROKERAGE = "brokerage";
|
||||
public static final String CATEGORY = "category";
|
||||
public static final String LAST_INT_DATE = "lastIntDate";
|
||||
public static final String CR_GL_ACCOUNT_ID = "crGlAccountId";
|
||||
public static final String SUB_TYPE = "subType";
|
||||
/** Optional legacy fields (trade no, authorities, lien, delivery mode, …) kept as a JSON object. */
|
||||
public static final String OTHER_DETAILS = "otherDetails";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
|
||||
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE, OTHER_DETAILS,
|
||||
LOCALE, DATE_FORMAT);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/purchases")
|
||||
@Component
|
||||
@Tag(name = "Investment Purchase Entry", description = "Investment deals (Govt Security, Bond, Mutual Fund, …) — each becomes a holding.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentPurchaseApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentPurchaseReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List investment purchases (holdings)")
|
||||
public List<InvestmentPurchaseData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
return this.readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{purchaseId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve an investment purchase")
|
||||
public InvestmentPurchaseData retrieveOne(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
return this.readPlatformService.retrieveOne(purchaseId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{purchaseId}/interest-schedule")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Interest schedule of a holding", description = "Half-yearly coupons and 31 Mar / 30 Sep closing provisions "
|
||||
+ "from purchase to maturity. Calculated only — nothing is posted. asOnDate (dd-MM-yyyy) defaults to the business date.")
|
||||
public InvestmentInterestScheduleData retrieveInterestSchedule(
|
||||
@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null
|
||||
: LocalDate.parse(asOnDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
|
||||
return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Save a purchase entry")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentPurchase().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{purchaseId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Drop a purchase entry")
|
||||
public CommandProcessingResult delete(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentPurchase(purchaseId).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/scripts")
|
||||
@Component
|
||||
@Tag(name = "Investment Script Master", description = "Securities / instruments the bank invests in (Govt Security, Bond, Mutual Fund, …).")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentScriptApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentMasterReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List investment scripts")
|
||||
public List<InvestmentScriptData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
|
||||
return this.readPlatformService.retrieveAllScripts();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{scriptId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve an investment script")
|
||||
public InvestmentScriptData retrieveOne(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
|
||||
return this.readPlatformService.retrieveScript(scriptId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create an investment script")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentScript().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{scriptId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update an investment script")
|
||||
public CommandProcessingResult update(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentScript(scriptId).withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Interest schedule of one holding: every half-yearly coupon and every 31 March / 30 September closing provision from
|
||||
* purchase to maturity. Calculated, not yet posted.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentInterestScheduleData {
|
||||
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final BigDecimal faceValue;
|
||||
private final BigDecimal unitValue;
|
||||
private final BigDecimal balanceUnits;
|
||||
private final BigDecimal purchaseRate;
|
||||
private final BigDecimal bookValue;
|
||||
private final BigDecimal intRate;
|
||||
private final String dealDate;
|
||||
private final String lastIntDate;
|
||||
private final String maturityDate;
|
||||
/** Interest paid to the seller at purchase — already in Interest Receivable. */
|
||||
private final BigDecimal purchasedAccruedInterest;
|
||||
private final BigDecimal couponPerHalfYear;
|
||||
private final String asOnDate;
|
||||
private final String interestGlCode;
|
||||
private final String interestGlName;
|
||||
private final List<Row> rows;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Row {
|
||||
|
||||
/** COUPON (cash received) or PROVISION (closing accrual). */
|
||||
private final String type;
|
||||
private final String intDate;
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final int days;
|
||||
/** Coupon received, or interest earned since the last coupon up to the closing. */
|
||||
private final BigDecimal grossInterest;
|
||||
/** Interest Receivable balance before this line (purchased accrued interest / earlier provision). */
|
||||
private final BigDecimal alreadyBooked;
|
||||
/** Provision to book now, or the part of the coupon that is new income. */
|
||||
private final BigDecimal amount;
|
||||
/** True when the date is on or before the as-on date. */
|
||||
private final boolean due;
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentPartyData {
|
||||
|
||||
private final Long id;
|
||||
private final String partyCode;
|
||||
private final String bondType;
|
||||
private final String partyName;
|
||||
private final String address1;
|
||||
private final String address2;
|
||||
private final String address3;
|
||||
private final String city;
|
||||
private final String pincode;
|
||||
private final String phone;
|
||||
private final String fax;
|
||||
private final String bankType;
|
||||
private final Long glAccountId;
|
||||
private final String glCode;
|
||||
private final String glName;
|
||||
private final String rbiCode;
|
||||
private final BigDecimal limit;
|
||||
private final String leiNo;
|
||||
private final String cpBankName;
|
||||
private final String cpBranchName;
|
||||
private final String cpAccountNo;
|
||||
private final String cpIfsc;
|
||||
private final boolean active;
|
||||
|
||||
public static InvestmentPartyData from(final InvestmentParty p) {
|
||||
return InvestmentPartyData.builder().id(p.getId()).partyCode(p.getPartyCode()).bondType(p.getBondType())
|
||||
.partyName(p.getPartyName()).address1(p.getAddress1()).address2(p.getAddress2()).address3(p.getAddress3())
|
||||
.city(p.getCity()).pincode(p.getPincode()).phone(p.getPhone()).fax(p.getFax()).bankType(p.getBankType())
|
||||
.glAccountId(p.getGlAccount().getId()).glCode(p.getGlAccount().getGlCode()).glName(p.getGlAccount().getName())
|
||||
.rbiCode(p.getRbiCode()).limit(p.getExposureLimit()).leiNo(p.getLeiNo()).cpBankName(p.getCpBankName())
|
||||
.cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc()).active(p.isActive()).build();
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentPurchaseData {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String bondType;
|
||||
private final Long scriptId;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String isin;
|
||||
private final Long partyId;
|
||||
private final String partyCode;
|
||||
private final String partyName;
|
||||
private final String dealDate;
|
||||
private final String dealTime;
|
||||
private final String settlementType;
|
||||
private final String settlementDate;
|
||||
private final BigDecimal faceValue;
|
||||
private final BigDecimal purchaseRate;
|
||||
private final BigDecimal units;
|
||||
private final BigDecimal balanceUnits;
|
||||
private final BigDecimal brokerage;
|
||||
private final BigDecimal stampDuty;
|
||||
private final BigDecimal bookValue;
|
||||
private final BigDecimal premiumDiscount;
|
||||
private final BigDecimal intRate;
|
||||
private final String issueDate;
|
||||
private final String maturityDate;
|
||||
private final String lastIntDate;
|
||||
private final Integer accruedDays;
|
||||
private final BigDecimal accruedInterest;
|
||||
private final BigDecimal settlementAmount;
|
||||
private final BigDecimal ytm;
|
||||
private final String category;
|
||||
private final String subType;
|
||||
private final Long investmentGlAccountId;
|
||||
private final String investmentGlCode;
|
||||
private final String investmentGlName;
|
||||
private final Long crGlAccountId;
|
||||
private final String crGlCode;
|
||||
private final String crGlName;
|
||||
private final String otherDetails;
|
||||
private final String status;
|
||||
|
||||
public static InvestmentPurchaseData from(final InvestmentPurchase p) {
|
||||
return InvestmentPurchaseData.builder().id(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()).bondType(p.getBondType())
|
||||
.scriptId(p.getScript().getId()).scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc())
|
||||
.isin(p.getScript().getIsin()).partyId(p.getParty().getId()).partyCode(p.getParty().getPartyCode())
|
||||
.partyName(p.getParty().getPartyName()).dealDate(format(p.getDealDate())).dealTime(p.getDealTime())
|
||||
.settlementType(p.getSettlementType()).settlementDate(format(p.getSettlementDate())).faceValue(p.getFaceValue())
|
||||
.purchaseRate(p.getPurchaseRate()).units(p.getUnits()).balanceUnits(p.getBalanceUnits()).brokerage(p.getBrokerage())
|
||||
.stampDuty(p.getStampDuty()).bookValue(p.getBookValue()).premiumDiscount(p.getPremiumDiscount()).intRate(p.getIntRate())
|
||||
.issueDate(format(p.getIssueDate())).maturityDate(format(p.getMaturityDate())).lastIntDate(format(p.getLastIntDate()))
|
||||
.accruedDays(p.getAccruedDays()).accruedInterest(p.getAccruedInterest()).settlementAmount(p.getSettlementAmount())
|
||||
.ytm(p.getYtm()).category(p.getCategory()).subType(p.getSubType())
|
||||
.investmentGlAccountId(p.getInvestmentGlAccount().getId()).investmentGlCode(p.getInvestmentGlAccount().getGlCode())
|
||||
.investmentGlName(p.getInvestmentGlAccount().getName()).crGlAccountId(p.getCrGlAccount().getId())
|
||||
.crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName()).otherDetails(p.getOtherDetails())
|
||||
.status(p.getStatus()).build();
|
||||
}
|
||||
|
||||
private static String format(final LocalDate date) {
|
||||
return date == null ? null : date.format(DATE_FORMAT);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentScriptData {
|
||||
|
||||
/** Dates go out as DD-MM-YYYY, the format the Investment screens use throughout. */
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final String scriptCode;
|
||||
private final String bondType;
|
||||
private final String slrType;
|
||||
private final String scriptDesc;
|
||||
private final Long glAccountId;
|
||||
private final String glCode;
|
||||
private final String glName;
|
||||
private final String isin;
|
||||
private final BigDecimal faceValuePerUnit;
|
||||
private final BigDecimal intRate;
|
||||
private final String issueDate;
|
||||
private final String maturityDate;
|
||||
private final String companyName;
|
||||
private final String address1;
|
||||
private final String address2;
|
||||
private final String address3;
|
||||
private final String city;
|
||||
private final String pincode;
|
||||
private final String phone;
|
||||
private final String fax;
|
||||
private final String corpCode;
|
||||
private final boolean active;
|
||||
|
||||
public static InvestmentScriptData from(final InvestmentScript s) {
|
||||
return InvestmentScriptData.builder().id(s.getId()).scriptCode(s.getScriptCode()).bondType(s.getBondType())
|
||||
.slrType(s.getSlrType()).scriptDesc(s.getScriptDesc()).glAccountId(s.getGlAccount().getId())
|
||||
.glCode(s.getGlAccount().getGlCode()).glName(s.getGlAccount().getName()).isin(s.getIsin())
|
||||
.faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate()))
|
||||
.maturityDate(format(s.getMaturityDate())).companyName(s.getCompanyName()).address1(s.getAddress1())
|
||||
.address2(s.getAddress2()).address3(s.getAddress3()).city(s.getCity()).pincode(s.getPincode()).phone(s.getPhone())
|
||||
.fax(s.getFax()).corpCode(s.getCorpCode()).active(s.isActive()).build();
|
||||
}
|
||||
|
||||
private static String format(final LocalDate date) {
|
||||
return date == null ? null : date.format(DATE_FORMAT);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/**
|
||||
* Bank / Party Master — the counterparty an investment is bought from or placed with (bank, RBI, issuer, AMC, broker),
|
||||
* following the bank's existing CBS Bank / Party Master.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_investment_party")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@Column(name = "party_code", nullable = false, length = 20, unique = true)
|
||||
private String partyCode;
|
||||
|
||||
@Column(name = "bond_type", nullable = false, length = 20)
|
||||
private String bondType;
|
||||
|
||||
@Column(name = "party_name", nullable = false, length = 150)
|
||||
private String partyName;
|
||||
|
||||
@Column(name = "address1", length = 150)
|
||||
private String address1;
|
||||
|
||||
@Column(name = "address2", length = 150)
|
||||
private String address2;
|
||||
|
||||
@Column(name = "address3", length = 150)
|
||||
private String address3;
|
||||
|
||||
@Column(name = "city", length = 100)
|
||||
private String city;
|
||||
|
||||
@Column(name = "pincode", length = 10)
|
||||
private String pincode;
|
||||
|
||||
@Column(name = "phone", length = 20)
|
||||
private String phone;
|
||||
|
||||
@Column(name = "fax", length = 20)
|
||||
private String fax;
|
||||
|
||||
@Column(name = "bank_type", length = 60)
|
||||
private String bankType;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "gl_account_id", nullable = false)
|
||||
private GLAccount glAccount;
|
||||
|
||||
@Column(name = "rbi_code", length = 30)
|
||||
private String rbiCode;
|
||||
|
||||
@Column(name = "exposure_limit", scale = 6, precision = 19)
|
||||
private BigDecimal exposureLimit;
|
||||
|
||||
@Column(name = "lei_no", length = 30)
|
||||
private String leiNo;
|
||||
|
||||
@Column(name = "cp_bank_name", length = 150)
|
||||
private String cpBankName;
|
||||
|
||||
@Column(name = "cp_branch_name", length = 150)
|
||||
private String cpBranchName;
|
||||
|
||||
@Column(name = "cp_account_no", length = 40)
|
||||
private String cpAccountNo;
|
||||
|
||||
@Column(name = "cp_ifsc", length = 11)
|
||||
private String cpIfsc;
|
||||
|
||||
/** An inactive party is kept for history but not offered for new deals. */
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active = true;
|
||||
}
|
||||
+3
-7
@@ -16,12 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
package org.apache.fineract.portfolio.investment.domain;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** A 15G / 15H / Form 121 declaration: one customer, one financial year, from the day it was accepted. */
|
||||
public record TdsDeclarationData(Long id, Long clientId, String fy, String formType, String uin, LocalDate acceptedOn,
|
||||
BigDecimal estimatedIncome, boolean revoked, LocalDate revokedOn, String revokeReason) implements Serializable {}
|
||||
public interface InvestmentPartyRepository extends JpaRepository<InvestmentParty, Long> {}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/**
|
||||
* Investment Purchase Entry — one deal (as in the bank's existing CBS "Govt.Security/Bond Entry"). Once saved it is the
|
||||
* holding that interest, sale and redemption later work against; {@link #balanceUnits} tracks what is still held.
|
||||
* Every derived figure (units, book value, accrued interest, settlement amount) is computed by the backend.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_investment_purchase")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
/** Financial-year deal number, e.g. 202627000001 for the first deal of FY 2026-27. */
|
||||
@Column(name = "deal_no", nullable = false, length = 20, unique = true)
|
||||
private String dealNo;
|
||||
|
||||
/** Certificate number — system-generated running number. */
|
||||
@Column(name = "cert_no", nullable = false, length = 20, unique = true)
|
||||
private String certNo;
|
||||
|
||||
@Column(name = "bond_type", nullable = false, length = 20)
|
||||
private String bondType;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "script_id", nullable = false)
|
||||
private InvestmentScript script;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "party_id", nullable = false)
|
||||
private InvestmentParty party;
|
||||
|
||||
@Column(name = "deal_date", nullable = false)
|
||||
private LocalDate dealDate;
|
||||
|
||||
@Column(name = "deal_time", length = 8)
|
||||
private String dealTime;
|
||||
|
||||
@Column(name = "settlement_type", nullable = false, length = 5)
|
||||
private String settlementType;
|
||||
|
||||
@Column(name = "settlement_date", nullable = false)
|
||||
private LocalDate settlementDate;
|
||||
|
||||
@Column(name = "face_value", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal faceValue;
|
||||
|
||||
/** Price per ₹100 face value (Govt Security / Bond); NAV for a mutual fund. */
|
||||
@Column(name = "purchase_rate", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal purchaseRate;
|
||||
|
||||
@Column(name = "units", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal units;
|
||||
|
||||
@Column(name = "balance_units", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal balanceUnits;
|
||||
|
||||
@Column(name = "brokerage", scale = 6, precision = 19)
|
||||
private BigDecimal brokerage;
|
||||
|
||||
@Column(name = "stamp_duty", scale = 6, precision = 19)
|
||||
private BigDecimal stampDuty;
|
||||
|
||||
@Column(name = "book_value", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal bookValue;
|
||||
|
||||
@Column(name = "premium_discount", scale = 6, precision = 19)
|
||||
private BigDecimal premiumDiscount;
|
||||
|
||||
@Column(name = "int_rate", scale = 4, precision = 9)
|
||||
private BigDecimal intRate;
|
||||
|
||||
@Column(name = "issue_date")
|
||||
private LocalDate issueDate;
|
||||
|
||||
@Column(name = "maturity_date")
|
||||
private LocalDate maturityDate;
|
||||
|
||||
@Column(name = "last_int_date")
|
||||
private LocalDate lastIntDate;
|
||||
|
||||
@Column(name = "accrued_days")
|
||||
private Integer accruedDays;
|
||||
|
||||
/** Interest paid to the seller from the last coupon date up to the deal date (30/360). */
|
||||
@Column(name = "accrued_interest", scale = 6, precision = 19)
|
||||
private BigDecimal accruedInterest;
|
||||
|
||||
@Column(name = "settlement_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal settlementAmount;
|
||||
|
||||
@Column(name = "ytm", scale = 4, precision = 9)
|
||||
private BigDecimal ytm;
|
||||
|
||||
@Column(name = "category", nullable = false, length = 40)
|
||||
private String category;
|
||||
|
||||
@Column(name = "sub_type", length = 40)
|
||||
private String subType;
|
||||
|
||||
/** Investment GL the holding is booked to (from the script). */
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "investment_gl_account_id", nullable = false)
|
||||
private GLAccount investmentGlAccount;
|
||||
|
||||
/** Bank GL the purchase is paid from ("CR. To A/c"). */
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "cr_gl_account_id", nullable = false)
|
||||
private GLAccount crGlAccount;
|
||||
|
||||
@Column(name = "other_details", length = 4000)
|
||||
private String otherDetails;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private String status;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface InvestmentPurchaseRepository extends JpaRepository<InvestmentPurchase, Long> {}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/**
|
||||
* Script Master — what is being bought (a Govt Security, bond, mutual fund scheme, share, …), following the bank's
|
||||
* existing CBS Script Master. A purchase picks these terms up instead of the dealer re-typing them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_investment_script")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class InvestmentScript extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
/** System-generated: bond-type letter + running number, e.g. G00001. */
|
||||
@Column(name = "script_code", nullable = false, length = 20, unique = true)
|
||||
private String scriptCode;
|
||||
|
||||
@Column(name = "bond_type", nullable = false, length = 20)
|
||||
private String bondType;
|
||||
|
||||
/** GOI / STATE_GOVT / TREASURY / NON_SLR. */
|
||||
@Column(name = "slr_type", length = 20)
|
||||
private String slrType;
|
||||
|
||||
@Column(name = "script_desc", nullable = false, length = 150)
|
||||
private String scriptDesc;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "gl_account_id", nullable = false)
|
||||
private GLAccount glAccount;
|
||||
|
||||
@Column(name = "isin", length = 20)
|
||||
private String isin;
|
||||
|
||||
@Column(name = "face_value_per_unit", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal faceValuePerUnit;
|
||||
|
||||
@Column(name = "int_rate", scale = 4, precision = 9)
|
||||
private BigDecimal intRate;
|
||||
|
||||
@Column(name = "issue_date")
|
||||
private LocalDate issueDate;
|
||||
|
||||
@Column(name = "maturity_date")
|
||||
private LocalDate maturityDate;
|
||||
|
||||
@Column(name = "company_name", length = 150)
|
||||
private String companyName;
|
||||
|
||||
@Column(name = "address1", length = 150)
|
||||
private String address1;
|
||||
|
||||
@Column(name = "address2", length = 150)
|
||||
private String address2;
|
||||
|
||||
@Column(name = "address3", length = 150)
|
||||
private String address3;
|
||||
|
||||
@Column(name = "city", length = 100)
|
||||
private String city;
|
||||
|
||||
@Column(name = "pincode", length = 10)
|
||||
private String pincode;
|
||||
|
||||
@Column(name = "phone", length = 20)
|
||||
private String phone;
|
||||
|
||||
@Column(name = "fax", length = 20)
|
||||
private String fax;
|
||||
|
||||
@Column(name = "corp_code", length = 30)
|
||||
private String corpCode;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active = true;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface InvestmentScriptRepository extends JpaRepository<InvestmentScript, Long> {}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
public class InvestmentPartyNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public InvestmentPartyNotFoundException(final Long id) {
|
||||
super("error.msg.investmentparty.id.invalid", "Investment party with identifier " + id + " does not exist", id);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
public class InvestmentPurchaseNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public InvestmentPurchaseNotFoundException(final Long id) {
|
||||
super("error.msg.investmentpurchase.id.invalid", "Investment purchase with identifier " + id + " does not exist", id);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
public class InvestmentScriptNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public InvestmentScriptNotFoundException(final Long id) {
|
||||
super("error.msg.investmentscript.id.invalid", "Investment script with identifier " + id + " does not exist", id);
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -16,28 +16,27 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.handler;
|
||||
package org.apache.fineract.portfolio.investment.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Tenure Flexibility: lengthens a running recurring deposit's term. */
|
||||
@Service
|
||||
@CommandType(entity = "INVESTMENTPARTY", action = "CREATE")
|
||||
@RequiredArgsConstructor
|
||||
@CommandType(entity = "RECURRINGDEPOSITACCOUNT", action = "EXTENDTENURE")
|
||||
public class ExtendTenureRecurringDepositAccountCommandHandler implements NewCommandSourceHandler {
|
||||
public class CreateInvestmentPartyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
private final InvestmentMasterWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.depositAccountWritePlatformService.extendRecurringDepositTenure(command.entityId(), command);
|
||||
return this.writePlatformService.createParty(command);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "INVESTMENTPURCHASE", action = "CREATE")
|
||||
@RequiredArgsConstructor
|
||||
public class CreateInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final InvestmentPurchaseWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createPurchase(command);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "INVESTMENTSCRIPT", action = "CREATE")
|
||||
@RequiredArgsConstructor
|
||||
public class CreateInvestmentScriptCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final InvestmentMasterWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createScript(command);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "INVESTMENTPARTY", action = "DELETE")
|
||||
@RequiredArgsConstructor
|
||||
public class DeleteInvestmentPartyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final InvestmentMasterWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.deleteParty(command.entityId());
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "INVESTMENTPURCHASE", action = "DELETE")
|
||||
@RequiredArgsConstructor
|
||||
public class DeleteInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final InvestmentPurchaseWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.deletePurchase(command.entityId());
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "INVESTMENTPARTY", action = "UPDATE")
|
||||
@RequiredArgsConstructor
|
||||
public class UpdateInvestmentPartyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final InvestmentMasterWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateParty(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "INVESTMENTSCRIPT", action = "UPDATE")
|
||||
@RequiredArgsConstructor
|
||||
public class UpdateInvestmentScriptCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final InvestmentMasterWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateScript(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.serialization;
|
||||
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS1;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS2;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS3;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BANK_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPES;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CITY;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.COMPANY_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CORP_CODE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_ACCOUNT_NO;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BANK_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BRANCH_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_IFSC;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.DEBT_BOND_TYPES;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.EXPOSURE_LIMIT;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_PARAMS;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_PARTY;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_SCRIPT;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_PARAMS;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPES;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Validates Bank / Party Master and Script Master requests before anything is written. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public final class InvestmentMasterCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final String IFSC_PATTERN = "^[A-Z]{4}0[A-Z0-9]{6}$";
|
||||
private static final String PINCODE_PATTERN = "^[0-9]{6}$";
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
// -------------------- Bank / Party Master --------------------
|
||||
|
||||
public void validateForCreateParty(final String json) {
|
||||
validateParty(json, true);
|
||||
}
|
||||
|
||||
public void validateForUpdateParty(final String json) {
|
||||
validateParty(json, false);
|
||||
}
|
||||
|
||||
private void validateParty(final String json, final boolean create) {
|
||||
final JsonElement element = parse(json, PARTY_PARAMS);
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE_PARTY);
|
||||
|
||||
validateBondType(element, v, create);
|
||||
validateRequiredString(element, v, PARTY_NAME, 150, create);
|
||||
validateGlAccountId(element, v, create);
|
||||
validateAddress(element, v);
|
||||
validateOptionalString(element, v, BANK_TYPE, 60);
|
||||
validateOptionalString(element, v, RBI_CODE, 30);
|
||||
validateOptionalString(element, v, LEI_NO, 30);
|
||||
validateOptionalString(element, v, CP_BANK_NAME, 150);
|
||||
validateOptionalString(element, v, CP_BRANCH_NAME, 150);
|
||||
validateOptionalString(element, v, CP_ACCOUNT_NO, 40);
|
||||
|
||||
final String ifsc = this.fromApiJsonHelper.extractStringNamed(CP_IFSC, element);
|
||||
if (StringUtils.isNotBlank(ifsc)) {
|
||||
v.reset().parameter(CP_IFSC).value(ifsc.trim().toUpperCase()).matchesRegularExpression(IFSC_PATTERN,
|
||||
"IFSC must be 11 characters: 4 letters, 0, then 6 letters/digits");
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(EXPOSURE_LIMIT, element)) {
|
||||
final BigDecimal limit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(EXPOSURE_LIMIT, element);
|
||||
v.reset().parameter(EXPOSURE_LIMIT).value(limit).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
throwIfErrors(errors);
|
||||
}
|
||||
|
||||
// -------------------- Script Master --------------------
|
||||
|
||||
public void validateForCreateScript(final String json) {
|
||||
validateScript(json, true);
|
||||
}
|
||||
|
||||
public void validateForUpdateScript(final String json, final String existingBondType) {
|
||||
validateScript(json, false, existingBondType);
|
||||
}
|
||||
|
||||
private void validateScript(final String json, final boolean create) {
|
||||
validateScript(json, create, null);
|
||||
}
|
||||
|
||||
private void validateScript(final String json, final boolean create, final String existingBondType) {
|
||||
final JsonElement element = parse(json, SCRIPT_PARAMS);
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE_SCRIPT);
|
||||
|
||||
validateBondType(element, v, create);
|
||||
validateRequiredString(element, v, SCRIPT_DESC, 150, create);
|
||||
validateGlAccountId(element, v, create);
|
||||
|
||||
final String slrType = this.fromApiJsonHelper.extractStringNamed(SLR_TYPE, element);
|
||||
if (StringUtils.isNotBlank(slrType)) {
|
||||
v.reset().parameter(SLR_TYPE).value(slrType).isOneOfTheseValues(SLR_TYPES.toArray());
|
||||
}
|
||||
|
||||
final String bondType = create ? this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element) : existingBondType;
|
||||
final boolean debt = bondType != null && DEBT_BOND_TYPES.contains(bondType);
|
||||
|
||||
final String isin = this.fromApiJsonHelper.extractStringNamed(ISIN, element);
|
||||
if (create && debt) {
|
||||
v.reset().parameter(ISIN).value(isin).notBlank();
|
||||
}
|
||||
v.reset().parameter(ISIN).value(isin).ignoreIfNull().notExceedingLengthOf(20);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(INT_RATE, element) || (create && debt)) {
|
||||
final BigDecimal intRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(INT_RATE, element);
|
||||
final DataValidatorBuilder rate = v.reset().parameter(INT_RATE).value(intRate);
|
||||
if (create && debt) {
|
||||
rate.notNull();
|
||||
}
|
||||
rate.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(FACE_VALUE_PER_UNIT, element)) {
|
||||
final BigDecimal fv = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FACE_VALUE_PER_UNIT, element);
|
||||
v.reset().parameter(FACE_VALUE_PER_UNIT).value(fv).ignoreIfNull().positiveAmount();
|
||||
}
|
||||
|
||||
LocalDate issueDate = null;
|
||||
if (this.fromApiJsonHelper.parameterExists(ISSUE_DATE, element) || (create && debt)) {
|
||||
issueDate = this.fromApiJsonHelper.extractLocalDateNamed(ISSUE_DATE, element);
|
||||
if (create && debt) {
|
||||
v.reset().parameter(ISSUE_DATE).value(issueDate).notNull();
|
||||
}
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(MATURITY_DATE, element)) {
|
||||
final LocalDate maturityDate = this.fromApiJsonHelper.extractLocalDateNamed(MATURITY_DATE, element);
|
||||
v.reset().parameter(MATURITY_DATE).value(maturityDate).ignoreIfNull().validateDateAfter(issueDate);
|
||||
}
|
||||
|
||||
validateOptionalString(element, v, COMPANY_NAME, 150);
|
||||
validateOptionalString(element, v, CORP_CODE, 30);
|
||||
validateAddress(element, v);
|
||||
throwIfErrors(errors);
|
||||
}
|
||||
|
||||
// -------------------- shared --------------------
|
||||
|
||||
private void validateBondType(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
|
||||
final String bondType = this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element);
|
||||
if (required) {
|
||||
v.reset().parameter(BOND_TYPE).value(bondType).notBlank();
|
||||
}
|
||||
if (StringUtils.isNotBlank(bondType)) {
|
||||
v.reset().parameter(BOND_TYPE).value(bondType).isOneOfTheseValues(BOND_TYPES.toArray());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGlAccountId(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
|
||||
if (!required && !this.fromApiJsonHelper.parameterExists(GL_ACCOUNT_ID, element)) {
|
||||
return;
|
||||
}
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
|
||||
v.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().longGreaterThanZero();
|
||||
}
|
||||
|
||||
private void validateRequiredString(final JsonElement element, final DataValidatorBuilder v, final String param, final int maxLength,
|
||||
final boolean required) {
|
||||
if (!required && !this.fromApiJsonHelper.parameterExists(param, element)) {
|
||||
return;
|
||||
}
|
||||
final String value = this.fromApiJsonHelper.extractStringNamed(param, element);
|
||||
v.reset().parameter(param).value(value).notBlank().notExceedingLengthOf(maxLength);
|
||||
}
|
||||
|
||||
private void validateOptionalString(final JsonElement element, final DataValidatorBuilder v, final String param,
|
||||
final int maxLength) {
|
||||
final String value = this.fromApiJsonHelper.extractStringNamed(param, element);
|
||||
v.reset().parameter(param).value(value).ignoreIfNull().notExceedingLengthOf(maxLength);
|
||||
}
|
||||
|
||||
private void validateAddress(final JsonElement element, final DataValidatorBuilder v) {
|
||||
validateOptionalString(element, v, ADDRESS1, 150);
|
||||
validateOptionalString(element, v, ADDRESS2, 150);
|
||||
validateOptionalString(element, v, ADDRESS3, 150);
|
||||
validateOptionalString(element, v, CITY, 100);
|
||||
validateOptionalString(element, v, PHONE, 20);
|
||||
validateOptionalString(element, v, FAX, 20);
|
||||
final String pincode = this.fromApiJsonHelper.extractStringNamed(PINCODE, element);
|
||||
if (StringUtils.isNotBlank(pincode)) {
|
||||
v.reset().parameter(PINCODE).value(pincode.trim()).matchesRegularExpression(PINCODE_PATTERN, "Pincode must be 6 digits");
|
||||
}
|
||||
}
|
||||
|
||||
private JsonElement parse(final String json, final Set<String> supportedParams) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParams);
|
||||
return this.fromApiJsonHelper.parse(json);
|
||||
}
|
||||
|
||||
private void throwIfErrors(final List<ApiParameterError> errors) {
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.serialization;
|
||||
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORIES;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_TIME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARAMS;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.RESOURCE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPES;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Validates a Purchase Entry before anything is computed or written. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final String TIME_PATTERN = "^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$";
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, PARAMS);
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
|
||||
final String bondType = this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element);
|
||||
v.reset().parameter(BOND_TYPE).value(bondType).notBlank();
|
||||
if (StringUtils.isNotBlank(bondType)) {
|
||||
v.reset().parameter(BOND_TYPE).value(bondType).isOneOfTheseValues(InvestmentApiConstants.BOND_TYPES.toArray());
|
||||
}
|
||||
v.reset().parameter(SCRIPT_ID).value(this.fromApiJsonHelper.extractLongNamed(SCRIPT_ID, element)).notNull().longGreaterThanZero();
|
||||
v.reset().parameter(PARTY_ID).value(this.fromApiJsonHelper.extractLongNamed(PARTY_ID, element)).notNull().longGreaterThanZero();
|
||||
v.reset().parameter(CR_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(CR_GL_ACCOUNT_ID, element)).notNull()
|
||||
.longGreaterThanZero();
|
||||
v.reset().parameter(DEAL_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(DEAL_DATE, element)).notNull();
|
||||
|
||||
final String settlementType = this.fromApiJsonHelper.extractStringNamed(SETTLEMENT_TYPE, element);
|
||||
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType).notBlank();
|
||||
if (StringUtils.isNotBlank(settlementType)) {
|
||||
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType).isOneOfTheseValues(SETTLEMENT_TYPES.toArray());
|
||||
}
|
||||
|
||||
final String category = this.fromApiJsonHelper.extractStringNamed(CATEGORY, element);
|
||||
v.reset().parameter(CATEGORY).value(category).notBlank();
|
||||
if (StringUtils.isNotBlank(category)) {
|
||||
v.reset().parameter(CATEGORY).value(category).isOneOfTheseValues(CATEGORIES.toArray());
|
||||
}
|
||||
|
||||
if ("MUTUAL_FUND".equals(bondType)) {
|
||||
v.reset().parameter(CONSIDER_AMT).value(amount(CONSIDER_AMT, element)).notNull().positiveAmount();
|
||||
v.reset().parameter(NAV).value(amount(NAV, element)).notNull().positiveAmount();
|
||||
} else {
|
||||
v.reset().parameter(FACE_VALUE).value(amount(FACE_VALUE, element)).notNull().positiveAmount();
|
||||
v.reset().parameter(PURCHASE_RATE).value(amount(PURCHASE_RATE, element)).notNull().positiveAmount();
|
||||
v.reset().parameter(LAST_INT_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(LAST_INT_DATE, element)).notNull();
|
||||
}
|
||||
v.reset().parameter(BROKERAGE).value(amount(BROKERAGE, element)).ignoreIfNull().zeroOrPositiveAmount();
|
||||
v.reset().parameter(STAMP_DUTY).value(amount(STAMP_DUTY, element)).ignoreIfNull().zeroOrPositiveAmount();
|
||||
|
||||
final String dealTime = this.fromApiJsonHelper.extractStringNamed(DEAL_TIME, element);
|
||||
if (StringUtils.isNotBlank(dealTime)) {
|
||||
v.reset().parameter(DEAL_TIME).value(dealTime).matchesRegularExpression(TIME_PATTERN, "Deal Time must be HH:mm:ss (24 hr)");
|
||||
}
|
||||
v.reset().parameter(SUB_TYPE).value(this.fromApiJsonHelper.extractStringNamed(SUB_TYPE, element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(40);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(OTHER_DETAILS, element)) {
|
||||
final JsonElement other = element.getAsJsonObject().get(OTHER_DETAILS);
|
||||
if (!other.isJsonNull() && !other.isJsonObject()) {
|
||||
v.reset().parameter(OTHER_DETAILS).failWithCode("must.be.an.object");
|
||||
} else if (other.isJsonObject() && other.toString().length() > 4000) {
|
||||
v.reset().parameter(OTHER_DETAILS).failWithCode("too.long");
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal amount(final String param, final JsonElement element) {
|
||||
return this.fromApiJsonHelper.parameterExists(param, element)
|
||||
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(param, element)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Month;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Interest arithmetic for half-yearly coupon securities, as the bank's existing CBS does it (Jitendra Sir):
|
||||
* <ul>
|
||||
* <li>days are counted 30/360 — every month is 30 days, a half-year is 180, a year 360;</li>
|
||||
* <li>the coupon is fixed: face value × rate ÷ 2, paid on the maturity day/month and six months either side;</li>
|
||||
* <li>at each closing (31 March, 30 September) the interest earned since the last coupon is booked as a provision (Dr
|
||||
* Interest Receivable / Cr Interest Income), even though the cash arrives at the next coupon;</li>
|
||||
* <li>when the coupon arrives, the provision already booked is cleared and only the rest is new income.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class InvestmentInterestCalculator {
|
||||
|
||||
private static final BigDecimal THIRTY_SIX_THOUSAND = BigDecimal.valueOf(36000);
|
||||
private static final BigDecimal TWO_HUNDRED = BigDecimal.valueOf(200);
|
||||
|
||||
public static final String TYPE_PROVISION = "PROVISION";
|
||||
public static final String TYPE_COUPON = "COUPON";
|
||||
|
||||
private InvestmentInterestCalculator() {}
|
||||
|
||||
/** One line of the interest schedule. */
|
||||
public record ScheduleRow(String type, LocalDate date, LocalDate fromDate, LocalDate toDate, int days, BigDecimal grossInterest,
|
||||
BigDecimal alreadyBooked, BigDecimal amount) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Days between two dates on a 30/360 basis. Counting runs from the day after {@code from} up to and including
|
||||
* {@code to}: 10-08 → 30-09 is 50 days, 10-08 → 08-10 is 58 days, 30-06 → 30-09 is 90 days.
|
||||
*/
|
||||
public static int days360(final LocalDate from, final LocalDate to) {
|
||||
if (from == null || to == null || !to.isAfter(from)) {
|
||||
return 0;
|
||||
}
|
||||
final int d1 = Math.min(from.getDayOfMonth(), 30);
|
||||
final int d2 = Math.min(to.getDayOfMonth(), 30);
|
||||
return (to.getYear() - from.getYear()) * 360 + (to.getMonthValue() - from.getMonthValue()) * 30 + (d2 - d1);
|
||||
}
|
||||
|
||||
/** Interest on {@code faceValue} at {@code ratePercent} p.a. for {@code days} (30/360), rounded to the rupee. */
|
||||
public static BigDecimal interestForDays(final BigDecimal faceValue, final BigDecimal ratePercent, final int days) {
|
||||
return faceValue.multiply(ratePercent).multiply(BigDecimal.valueOf(days)).divide(THIRTY_SIX_THOUSAND, 0, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** The fixed half-yearly coupon: face value × rate ÷ 2. */
|
||||
public static BigDecimal halfYearCoupon(final BigDecimal faceValue, final BigDecimal ratePercent) {
|
||||
return faceValue.multiply(ratePercent).divide(TWO_HUNDRED, 0, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** k-th coupon date counting back from maturity — measured from maturity each time so month-end days survive. */
|
||||
private static LocalDate couponBack(final LocalDate maturity, final int k) {
|
||||
return maturity.minusMonths(6L * k);
|
||||
}
|
||||
|
||||
/** The most recent coupon date on or before {@code on}. */
|
||||
public static LocalDate lastCouponOnOrBefore(final LocalDate maturity, final LocalDate on) {
|
||||
int k = 0;
|
||||
LocalDate d = maturity;
|
||||
while (d.isAfter(on)) {
|
||||
k++;
|
||||
d = couponBack(maturity, k);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Coupon dates strictly after {@code from}, up to and including maturity, in date order. */
|
||||
public static List<LocalDate> couponDatesAfter(final LocalDate maturity, final LocalDate from) {
|
||||
final List<LocalDate> dates = new ArrayList<>();
|
||||
int k = 0;
|
||||
LocalDate d = maturity;
|
||||
while (d.isAfter(from)) {
|
||||
dates.add(0, d);
|
||||
k++;
|
||||
d = couponBack(maturity, k);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/** Closing dates (31 March, 30 September) from {@code from} up to {@code to}, inclusive. */
|
||||
public static List<LocalDate> closingDates(final LocalDate from, final LocalDate to) {
|
||||
final List<LocalDate> dates = new ArrayList<>();
|
||||
for (int year = from.getYear(); year <= to.getYear(); year++) {
|
||||
for (final LocalDate closing : List.of(LocalDate.of(year, Month.MARCH, 31), LocalDate.of(year, Month.SEPTEMBER, 30))) {
|
||||
if (!closing.isBefore(from) && !closing.isAfter(to)) {
|
||||
dates.add(closing);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full interest schedule of a holding, from purchase to maturity.
|
||||
*
|
||||
* @param faceValue
|
||||
* face value currently held
|
||||
* @param ratePercent
|
||||
* coupon rate % p.a.
|
||||
* @param maturity
|
||||
* maturity date (coupons fall on its day/month and six months either side)
|
||||
* @param lastIntDate
|
||||
* coupon date immediately before the purchase
|
||||
* @param dealDate
|
||||
* purchase deal date — closings before it belong to the seller
|
||||
* @param purchasedAccrued
|
||||
* interest paid to the seller at purchase; it already sits in Interest Receivable
|
||||
*/
|
||||
public static List<ScheduleRow> schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity,
|
||||
final LocalDate lastIntDate, final LocalDate dealDate, final BigDecimal purchasedAccrued) {
|
||||
final List<ScheduleRow> rows = new ArrayList<>();
|
||||
if (faceValue == null || ratePercent == null || ratePercent.signum() <= 0 || maturity == null || lastIntDate == null
|
||||
|| dealDate == null || !maturity.isAfter(lastIntDate)) {
|
||||
return rows;
|
||||
}
|
||||
final List<LocalDate> coupons = couponDatesAfter(maturity, lastIntDate);
|
||||
final TreeSet<LocalDate> events = new TreeSet<>(coupons);
|
||||
for (final LocalDate closing : closingDates(dealDate, maturity)) {
|
||||
if (closing.isAfter(lastIntDate) && !events.contains(closing)) {
|
||||
events.add(closing);
|
||||
}
|
||||
}
|
||||
|
||||
final BigDecimal coupon = halfYearCoupon(faceValue, ratePercent);
|
||||
BigDecimal receivable = purchasedAccrued == null ? BigDecimal.ZERO : purchasedAccrued;
|
||||
LocalDate previousCoupon = lastIntDate;
|
||||
for (final LocalDate date : events) {
|
||||
final int days = days360(previousCoupon, date);
|
||||
if (coupons.contains(date)) {
|
||||
rows.add(new ScheduleRow(TYPE_COUPON, date, previousCoupon.plusDays(1), date, days, coupon, receivable,
|
||||
coupon.subtract(receivable)));
|
||||
receivable = BigDecimal.ZERO;
|
||||
previousCoupon = date;
|
||||
} else {
|
||||
final BigDecimal earned = interestForDays(faceValue, ratePercent, days);
|
||||
rows.add(new ScheduleRow(TYPE_PROVISION, date, previousCoupon.plusDays(1), date, days, earned, receivable,
|
||||
earned.subtract(receivable)));
|
||||
receivable = earned;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
|
||||
|
||||
public interface InvestmentMasterReadPlatformService {
|
||||
|
||||
List<InvestmentPartyData> retrieveAllParties();
|
||||
|
||||
InvestmentPartyData retrieveParty(Long partyId);
|
||||
|
||||
List<InvestmentScriptData> retrieveAllScripts();
|
||||
|
||||
InvestmentScriptData retrieveScript(Long scriptId);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Masters are small (tens of rows), so they are listed whole, newest first, the way the screens show them. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class InvestmentMasterReadPlatformServiceImpl implements InvestmentMasterReadPlatformService {
|
||||
|
||||
private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "id");
|
||||
|
||||
private final InvestmentPartyRepository partyRepository;
|
||||
private final InvestmentScriptRepository scriptRepository;
|
||||
|
||||
@Override
|
||||
public List<InvestmentPartyData> retrieveAllParties() {
|
||||
return this.partyRepository.findAll(NEWEST_FIRST).stream().map(InvestmentPartyData::from).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InvestmentPartyData retrieveParty(final Long partyId) {
|
||||
return this.partyRepository.findById(partyId).map(InvestmentPartyData::from)
|
||||
.orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<InvestmentScriptData> retrieveAllScripts() {
|
||||
return this.scriptRepository.findAll(NEWEST_FIRST).stream().map(InvestmentScriptData::from).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InvestmentScriptData retrieveScript(final Long scriptId) {
|
||||
return this.scriptRepository.findById(scriptId).map(InvestmentScriptData::from)
|
||||
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface InvestmentMasterWritePlatformService {
|
||||
|
||||
CommandProcessingResult createParty(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateParty(Long partyId, JsonCommand command);
|
||||
|
||||
CommandProcessingResult deleteParty(Long partyId);
|
||||
|
||||
CommandProcessingResult createScript(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateScript(Long scriptId, JsonCommand command);
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ACTIVE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS1;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS2;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS3;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BANK_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CITY;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.COMPANY_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CORP_CODE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_ACCOUNT_NO;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BANK_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BRANCH_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_IFSC;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.EXPOSURE_LIMIT;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_CODE_PREFIX;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.function.Consumer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
|
||||
import org.apache.fineract.portfolio.investment.serialization.InvestmentMasterCommandFromApiJsonDeserializer;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMasterWritePlatformService {
|
||||
|
||||
private final InvestmentMasterCommandFromApiJsonDeserializer validator;
|
||||
private final InvestmentPartyRepository partyRepository;
|
||||
private final InvestmentScriptRepository scriptRepository;
|
||||
private final GLAccountRepositoryWrapper glAccountRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// -------------------- Bank / Party Master --------------------
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult createParty(final JsonCommand command) {
|
||||
this.validator.validateForCreateParty(command.json());
|
||||
final InvestmentParty party = new InvestmentParty();
|
||||
party.setPartyCode(nextPartyCode());
|
||||
applyParty(party, command);
|
||||
if (command.parameterExists(ACTIVE)) {
|
||||
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
|
||||
}
|
||||
final InvestmentParty saved = saveParty(party);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
|
||||
.withResourceIdAsString(saved.getPartyCode()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult updateParty(final Long partyId, final JsonCommand command) {
|
||||
this.validator.validateForUpdateParty(command.json());
|
||||
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
|
||||
applyParty(party, command);
|
||||
if (command.parameterExists(ACTIVE)) {
|
||||
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
|
||||
}
|
||||
saveParty(party);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId)
|
||||
.withResourceIdAsString(party.getPartyCode()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult deleteParty(final Long partyId) {
|
||||
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
|
||||
this.partyRepository.delete(party);
|
||||
this.partyRepository.flush();
|
||||
return new CommandProcessingResultBuilder().withEntityId(partyId).withResourceIdAsString(party.getPartyCode()).build();
|
||||
}
|
||||
|
||||
/** Copies every party field present in the request onto the entity (absent fields are left unchanged). */
|
||||
private void applyParty(final InvestmentParty p, final JsonCommand command) {
|
||||
setIfPresent(command, BOND_TYPE, p::setBondType);
|
||||
setIfPresent(command, PARTY_NAME, p::setPartyName);
|
||||
setIfPresent(command, ADDRESS1, p::setAddress1);
|
||||
setIfPresent(command, ADDRESS2, p::setAddress2);
|
||||
setIfPresent(command, ADDRESS3, p::setAddress3);
|
||||
setIfPresent(command, CITY, p::setCity);
|
||||
setIfPresent(command, PINCODE, p::setPincode);
|
||||
setIfPresent(command, PHONE, p::setPhone);
|
||||
setIfPresent(command, FAX, p::setFax);
|
||||
setIfPresent(command, BANK_TYPE, p::setBankType);
|
||||
setIfPresent(command, RBI_CODE, p::setRbiCode);
|
||||
setIfPresent(command, LEI_NO, p::setLeiNo);
|
||||
setIfPresent(command, CP_BANK_NAME, p::setCpBankName);
|
||||
setIfPresent(command, CP_BRANCH_NAME, p::setCpBranchName);
|
||||
setIfPresent(command, CP_ACCOUNT_NO, p::setCpAccountNo);
|
||||
if (command.parameterExists(CP_IFSC)) {
|
||||
final String ifsc = command.stringValueOfParameterNamedAllowingNull(CP_IFSC);
|
||||
p.setCpIfsc(StringUtils.isBlank(ifsc) ? null : ifsc.trim().toUpperCase());
|
||||
}
|
||||
if (command.parameterExists(EXPOSURE_LIMIT)) {
|
||||
p.setExposureLimit(command.bigDecimalValueOfParameterNamed(EXPOSURE_LIMIT));
|
||||
}
|
||||
if (command.parameterExists(GL_ACCOUNT_ID)) {
|
||||
p.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID)));
|
||||
}
|
||||
}
|
||||
|
||||
private InvestmentParty saveParty(final InvestmentParty party) {
|
||||
try {
|
||||
return this.partyRepository.saveAndFlush(party);
|
||||
} catch (final DataIntegrityViolationException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.investmentparty.duplicate", "A party with this code already exists",
|
||||
"partyCode", party.getPartyCode(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Next code in the PTY-001, PTY-002 … series. */
|
||||
private String nextPartyCode() {
|
||||
final Integer max = this.jdbcTemplate.queryForObject(
|
||||
"SELECT MAX(CAST(SUBSTRING(party_code FROM 5) AS INTEGER)) FROM m_investment_party WHERE party_code ~ '^PTY-[0-9]+$'",
|
||||
Integer.class);
|
||||
return PARTY_CODE_PREFIX + String.format("%03d", (max == null ? 0 : max) + 1);
|
||||
}
|
||||
|
||||
// -------------------- Script Master --------------------
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult createScript(final JsonCommand command) {
|
||||
this.validator.validateForCreateScript(command.json());
|
||||
final InvestmentScript script = new InvestmentScript();
|
||||
script.setBondType(command.stringValueOfParameterNamed(BOND_TYPE));
|
||||
script.setScriptCode(nextScriptCode(script.getBondType()));
|
||||
script.setFaceValuePerUnit(defaultFaceValue(script.getBondType()));
|
||||
applyScript(script, command);
|
||||
if (command.parameterExists(ACTIVE)) {
|
||||
script.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
|
||||
}
|
||||
final InvestmentScript saved = saveScript(script);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
|
||||
.withResourceIdAsString(saved.getScriptCode()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult updateScript(final Long scriptId, final JsonCommand command) {
|
||||
final InvestmentScript script = this.scriptRepository.findById(scriptId)
|
||||
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
|
||||
this.validator.validateForUpdateScript(command.json(), script.getBondType());
|
||||
if (command.parameterExists(BOND_TYPE) && !script.getBondType().equals(command.stringValueOfParameterNamed(BOND_TYPE))) {
|
||||
// The script code carries the bond-type letter (G00001), so the type cannot change once created.
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.bondtype.immutable",
|
||||
"Bond Type of an existing script cannot be changed", script.getScriptCode());
|
||||
}
|
||||
applyScript(script, command);
|
||||
if (command.parameterExists(ACTIVE)) {
|
||||
script.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
|
||||
}
|
||||
saveScript(script);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(scriptId)
|
||||
.withResourceIdAsString(script.getScriptCode()).build();
|
||||
}
|
||||
|
||||
private void applyScript(final InvestmentScript s, final JsonCommand command) {
|
||||
setIfPresent(command, SLR_TYPE, s::setSlrType);
|
||||
setIfPresent(command, SCRIPT_DESC, s::setScriptDesc);
|
||||
setIfPresent(command, ISIN, s::setIsin);
|
||||
setIfPresent(command, COMPANY_NAME, s::setCompanyName);
|
||||
setIfPresent(command, ADDRESS1, s::setAddress1);
|
||||
setIfPresent(command, ADDRESS2, s::setAddress2);
|
||||
setIfPresent(command, ADDRESS3, s::setAddress3);
|
||||
setIfPresent(command, CITY, s::setCity);
|
||||
setIfPresent(command, PINCODE, s::setPincode);
|
||||
setIfPresent(command, PHONE, s::setPhone);
|
||||
setIfPresent(command, FAX, s::setFax);
|
||||
setIfPresent(command, CORP_CODE, s::setCorpCode);
|
||||
if (command.parameterExists(FACE_VALUE_PER_UNIT) && command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT) != null) {
|
||||
s.setFaceValuePerUnit(command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT));
|
||||
}
|
||||
if (command.parameterExists(INT_RATE)) {
|
||||
s.setIntRate(command.bigDecimalValueOfParameterNamed(INT_RATE));
|
||||
}
|
||||
if (command.parameterExists(ISSUE_DATE)) {
|
||||
s.setIssueDate(command.localDateValueOfParameterNamed(ISSUE_DATE));
|
||||
}
|
||||
if (command.parameterExists(MATURITY_DATE)) {
|
||||
s.setMaturityDate(command.localDateValueOfParameterNamed(MATURITY_DATE));
|
||||
}
|
||||
if (s.getIssueDate() != null && s.getMaturityDate() != null && s.getMaturityDate().isBefore(s.getIssueDate())) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.maturity.before.issue",
|
||||
"Maturity date cannot be before the issue date");
|
||||
}
|
||||
if (command.parameterExists(GL_ACCOUNT_ID)) {
|
||||
s.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID)));
|
||||
}
|
||||
}
|
||||
|
||||
private InvestmentScript saveScript(final InvestmentScript script) {
|
||||
try {
|
||||
return this.scriptRepository.saveAndFlush(script);
|
||||
} catch (final DataIntegrityViolationException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.investmentscript.duplicate", "A script with this code already exists",
|
||||
"scriptCode", script.getScriptCode(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Next code for the bond type — its letter plus a five-digit running number, e.g. G00001. */
|
||||
private String nextScriptCode(final String bondType) {
|
||||
final String prefix = SCRIPT_CODE_PREFIX.get(bondType);
|
||||
final Integer max = this.jdbcTemplate.queryForObject(
|
||||
"SELECT MAX(CAST(SUBSTRING(script_code FROM 2) AS INTEGER)) FROM m_investment_script WHERE script_code ~ ?",
|
||||
Integer.class, "^" + prefix + "[0-9]{5}$");
|
||||
return prefix + String.format("%05d", (max == null ? 0 : max) + 1);
|
||||
}
|
||||
|
||||
/** Face value of one unit: ₹100 for securities and bonds, as the existing CBS defaults it. */
|
||||
private BigDecimal defaultFaceValue(final String bondType) {
|
||||
return switch (bondType) {
|
||||
case "MUTUAL_FUND" -> BigDecimal.valueOf(1000);
|
||||
case "SHARE", "FD" -> BigDecimal.ONE;
|
||||
default -> BigDecimal.valueOf(100);
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------- shared --------------------
|
||||
|
||||
/** An investment is an asset, so the mapped GL must be an enabled ASSET detail account. */
|
||||
private GLAccount postableAssetGl(final Long glAccountId) {
|
||||
final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId);
|
||||
if (gl.isHeaderAccount()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.header",
|
||||
"GL " + gl.getGlCode() + " is a header account; choose a detail account", glAccountId);
|
||||
}
|
||||
if (gl.isDisabled()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.disabled", "GL " + gl.getGlCode() + " is disabled",
|
||||
glAccountId);
|
||||
}
|
||||
if (!GLAccountType.ASSET.getValue().equals(gl.getType())) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.not.asset",
|
||||
"GL " + gl.getGlCode() + " is not an Asset account", glAccountId);
|
||||
}
|
||||
return gl;
|
||||
}
|
||||
|
||||
private static void setIfPresent(final JsonCommand command, final String param, final Consumer<String> setter) {
|
||||
if (command.parameterExists(param)) {
|
||||
final String value = command.stringValueOfParameterNamedAllowingNull(param);
|
||||
setter.accept(StringUtils.isBlank(value) ? null : value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class InvestmentPurchaseReadPlatformService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
/** Income head for coupon and provision interest (GL 400401 under 400400 INCOME FROM INVESTMENTS). */
|
||||
private static final String INTEREST_GL_CODE = "400401";
|
||||
private static final String INTEREST_GL_NAME = "INTEREST ON GOVT SECURITIES";
|
||||
|
||||
private final InvestmentPurchaseRepository purchaseRepository;
|
||||
|
||||
/** All deals, newest first — the Holdings register. */
|
||||
public List<InvestmentPurchaseData> retrieveAll() {
|
||||
return this.purchaseRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentPurchaseData::from).toList();
|
||||
}
|
||||
|
||||
public InvestmentPurchaseData retrieveOne(final Long purchaseId) {
|
||||
return InvestmentPurchaseData.from(find(purchaseId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon and closing-provision lines of a holding from purchase to maturity, worked out on the face value still
|
||||
* held. Lines on or before {@code asOnDate} (default: business date) are marked due.
|
||||
*/
|
||||
public InvestmentInterestScheduleData retrieveInterestSchedule(final Long purchaseId, final LocalDate asOnDate) {
|
||||
final InvestmentPurchase p = find(purchaseId);
|
||||
final LocalDate asOn = asOnDate == null ? DateUtils.getBusinessLocalDate() : asOnDate;
|
||||
final BigDecimal unitValue = p.getScript().getFaceValuePerUnit();
|
||||
final BigDecimal faceHeld = p.getBalanceUnits().multiply(unitValue);
|
||||
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
|
||||
|
||||
final List<InvestmentInterestScheduleData.Row> rows = InvestmentInterestCalculator
|
||||
.schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getDealDate(), p.getAccruedInterest()).stream()
|
||||
.map(r -> InvestmentInterestScheduleData.Row.builder().type(r.type()).intDate(format(r.date()))
|
||||
.fromDate(format(r.fromDate())).toDate(format(r.toDate())).days(r.days()).grossInterest(r.grossInterest())
|
||||
.alreadyBooked(r.alreadyBooked()).amount(r.amount()).due(!r.date().isAfter(asOn)).build())
|
||||
.toList();
|
||||
|
||||
return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
|
||||
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld)
|
||||
.unitValue(unitValue).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate()).bookValue(p.getBookValue())
|
||||
.intRate(p.getIntRate()).dealDate(format(p.getDealDate())).lastIntDate(format(p.getLastIntDate()))
|
||||
.maturityDate(format(p.getMaturityDate())).purchasedAccruedInterest(p.getAccruedInterest())
|
||||
.couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO)
|
||||
.asOnDate(format(asOn)).interestGlCode(INTEREST_GL_CODE).interestGlName(INTEREST_GL_NAME).rows(rows).build();
|
||||
}
|
||||
|
||||
private InvestmentPurchase find(final Long purchaseId) {
|
||||
return this.purchaseRepository.findById(purchaseId).orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
|
||||
}
|
||||
|
||||
private static String format(final LocalDate date) {
|
||||
return date == null ? null : date.format(DATE_FORMAT);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface InvestmentPurchaseWritePlatformService {
|
||||
|
||||
CommandProcessingResult createPurchase(JsonCommand command);
|
||||
|
||||
CommandProcessingResult deletePurchase(Long purchaseId);
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_TIME;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
|
||||
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
|
||||
import org.apache.fineract.portfolio.investment.serialization.InvestmentPurchaseCommandFromApiJsonDeserializer;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Saves a Purchase Entry as a holding. Every derived figure is computed here from the script master and the deal
|
||||
* inputs, so the stored deal does not depend on what the screen calculated. No GL entry is posted yet.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPurchaseWritePlatformService {
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final InvestmentPurchaseCommandFromApiJsonDeserializer validator;
|
||||
private final InvestmentPurchaseRepository purchaseRepository;
|
||||
private final InvestmentScriptRepository scriptRepository;
|
||||
private final InvestmentPartyRepository partyRepository;
|
||||
private final GLAccountRepositoryWrapper glAccountRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult createPurchase(final JsonCommand command) {
|
||||
this.validator.validateForCreate(command.json());
|
||||
|
||||
final String bondType = command.stringValueOfParameterNamed(BOND_TYPE);
|
||||
final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID);
|
||||
final InvestmentScript script = this.scriptRepository.findById(scriptId)
|
||||
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
|
||||
final Long partyId = command.longValueOfParameterNamed(PARTY_ID);
|
||||
final InvestmentParty party = this.partyRepository.findById(partyId)
|
||||
.orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
|
||||
if (!script.isActive()) {
|
||||
throw rule("script.inactive", "Script " + script.getScriptCode() + " is inactive");
|
||||
}
|
||||
if (!script.getBondType().equals(bondType)) {
|
||||
throw rule("script.bondtype.mismatch",
|
||||
"Script " + script.getScriptCode() + " is a " + script.getBondType() + ", not " + bondType);
|
||||
}
|
||||
if (!party.isActive()) {
|
||||
throw rule("party.inactive", "Party " + party.getPartyCode() + " is inactive");
|
||||
}
|
||||
|
||||
final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE);
|
||||
if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) {
|
||||
throw rule("deal.after.maturity", "Deal date is after the security's maturity date " + script.getMaturityDate());
|
||||
}
|
||||
final String settlementType = command.stringValueOfParameterNamed(SETTLEMENT_TYPE);
|
||||
|
||||
final InvestmentPurchase p = new InvestmentPurchase();
|
||||
p.setDealNo(nextDealNo(dealDate));
|
||||
p.setCertNo(nextCertNo());
|
||||
p.setBondType(bondType);
|
||||
p.setScript(script);
|
||||
p.setParty(party);
|
||||
p.setDealDate(dealDate);
|
||||
p.setDealTime(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(DEAL_TIME)));
|
||||
p.setSettlementType(settlementType);
|
||||
p.setSettlementDate(dealDate.plusDays(Long.parseLong(settlementType.substring(2))));
|
||||
p.setCategory(command.stringValueOfParameterNamed(CATEGORY));
|
||||
p.setSubType(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)));
|
||||
p.setIntRate(script.getIntRate());
|
||||
p.setIssueDate(script.getIssueDate());
|
||||
p.setMaturityDate(script.getMaturityDate());
|
||||
p.setInvestmentGlAccount(script.getGlAccount());
|
||||
p.setCrGlAccount(postableAssetGl(command.longValueOfParameterNamed(CR_GL_ACCOUNT_ID)));
|
||||
p.setOtherDetails(otherDetails(command));
|
||||
p.setStatus(STATUS_ACTIVE);
|
||||
|
||||
final BigDecimal brokerage = zeroIfNull(command.bigDecimalValueOfParameterNamed(BROKERAGE));
|
||||
p.setBrokerage(brokerage);
|
||||
if ("MUTUAL_FUND".equals(bondType)) {
|
||||
computeMutualFund(p, command, brokerage);
|
||||
} else {
|
||||
computeSecurity(p, command, script, brokerage);
|
||||
}
|
||||
|
||||
final InvestmentPurchase saved = this.purchaseRepository.saveAndFlush(p);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
|
||||
.withResourceIdAsString(saved.getDealNo()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult deletePurchase(final Long purchaseId) {
|
||||
final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId)
|
||||
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
|
||||
if (p.getBalanceUnits().compareTo(p.getUnits()) != 0) {
|
||||
throw rule("purchase.partly.sold", "Deal " + p.getDealNo() + " has been partly sold and cannot be dropped");
|
||||
}
|
||||
this.purchaseRepository.delete(p);
|
||||
this.purchaseRepository.flush();
|
||||
return new CommandProcessingResultBuilder().withEntityId(purchaseId).withResourceIdAsString(p.getDealNo()).build();
|
||||
}
|
||||
|
||||
/** Govt Security / Bond: face value at a price per ₹100, plus interest owed to the seller since the last coupon. */
|
||||
private void computeSecurity(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script,
|
||||
final BigDecimal brokerage) {
|
||||
final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
|
||||
final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
|
||||
final LocalDate lastIntDate = command.localDateValueOfParameterNamed(LAST_INT_DATE);
|
||||
if (lastIntDate.isAfter(p.getDealDate())) {
|
||||
throw rule("lastint.after.deal", "Last interest date cannot be after the deal date");
|
||||
}
|
||||
final BigDecimal units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP);
|
||||
final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getDealDate());
|
||||
final BigDecimal intRate = zeroIfNull(script.getIntRate());
|
||||
// Interest is counted up to the deal date (the event), not the settlement date — Jitendra Sir.
|
||||
final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(faceValue, intRate, days);
|
||||
|
||||
p.setFaceValue(faceValue);
|
||||
p.setPurchaseRate(rate);
|
||||
p.setUnits(units);
|
||||
p.setBalanceUnits(units);
|
||||
p.setBookValue(cost.add(brokerage));
|
||||
p.setPremiumDiscount(faceValue.multiply(rate.subtract(HUNDRED)).divide(HUNDRED, 2, RoundingMode.HALF_UP));
|
||||
p.setLastIntDate(lastIntDate);
|
||||
p.setAccruedDays(days);
|
||||
p.setAccruedInterest(accrued);
|
||||
p.setSettlementAmount(p.getBookValue().add(accrued));
|
||||
p.setYtm(approxYtm(intRate, rate, script.getIssueDate(), script.getMaturityDate()));
|
||||
}
|
||||
|
||||
/** Mutual fund: the amount invested buys units at the NAV; stamp duty and brokerage add to the cost. */
|
||||
private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command, final BigDecimal brokerage) {
|
||||
final BigDecimal amount = command.bigDecimalValueOfParameterNamed(CONSIDER_AMT);
|
||||
final BigDecimal nav = command.bigDecimalValueOfParameterNamed(NAV);
|
||||
final BigDecimal stampDuty = zeroIfNull(command.bigDecimalValueOfParameterNamed(STAMP_DUTY));
|
||||
final BigDecimal units = amount.divide(nav, 3, RoundingMode.HALF_UP);
|
||||
|
||||
p.setFaceValue(amount);
|
||||
p.setPurchaseRate(nav);
|
||||
p.setUnits(units);
|
||||
p.setBalanceUnits(units);
|
||||
p.setStampDuty(stampDuty);
|
||||
p.setBookValue(amount.add(stampDuty).add(brokerage));
|
||||
p.setAccruedInterest(BigDecimal.ZERO);
|
||||
p.setAccruedDays(0);
|
||||
p.setSettlementAmount(p.getBookValue());
|
||||
}
|
||||
|
||||
/** Approximate yield: coupon adjusted for the discount/premium spread over the security's life, over the price. */
|
||||
static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate issue, final LocalDate maturity) {
|
||||
if (coupon.signum() == 0 || issue == null || maturity == null || !maturity.isAfter(issue)) {
|
||||
return coupon;
|
||||
}
|
||||
final double years = ChronoUnit.DAYS.between(issue, maturity) / 365.25;
|
||||
final double ytm = (coupon.doubleValue() + (100 - price.doubleValue()) / years) / price.doubleValue() * 100;
|
||||
return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** Financial-year deal number: FY start year + FY end (2 digits) + 6-digit running number, e.g. 202627000001. */
|
||||
private String nextDealNo(final LocalDate dealDate) {
|
||||
final String prefix = dealNoPrefix(dealDate);
|
||||
final Integer max = this.jdbcTemplate.queryForObject(
|
||||
"SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class,
|
||||
prefix + "%");
|
||||
return prefix + String.format("%06d", (max == null ? 0 : max) + 1);
|
||||
}
|
||||
|
||||
/** Financial year (April–March) of the deal date: 2026-04-01 … 2027-03-31 → "202627". */
|
||||
static String dealNoPrefix(final LocalDate dealDate) {
|
||||
final int fyStart = dealDate.getMonthValue() >= 4 ? dealDate.getYear() : dealDate.getYear() - 1;
|
||||
return fyStart + String.format("%02d", (fyStart + 1) % 100);
|
||||
}
|
||||
|
||||
private String nextCertNo() {
|
||||
final Integer max = this.jdbcTemplate.queryForObject(
|
||||
"SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class);
|
||||
return String.valueOf((max == null ? 0 : max) + 1);
|
||||
}
|
||||
|
||||
private static String otherDetails(final JsonCommand command) {
|
||||
if (!command.parameterExists(OTHER_DETAILS)) {
|
||||
return null;
|
||||
}
|
||||
final JsonElement other = command.parsedJson().getAsJsonObject().get(OTHER_DETAILS);
|
||||
return other == null || other.isJsonNull() ? null : other.toString();
|
||||
}
|
||||
|
||||
/** The paying bank account must be an enabled ASSET detail GL. */
|
||||
private GLAccount postableAssetGl(final Long glAccountId) {
|
||||
final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId);
|
||||
if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType())) {
|
||||
throw rule("cr.glaccount.invalid", "CR. To A/c " + gl.getGlCode() + " must be an enabled Asset detail account");
|
||||
}
|
||||
return gl;
|
||||
}
|
||||
|
||||
private static BigDecimal zeroIfNull(final BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
|
||||
return new GeneralPlatformDomainRuleException("error.msg.investmentpurchase." + code, message);
|
||||
}
|
||||
}
|
||||
+1
-8
@@ -55,7 +55,6 @@ import org.apache.fineract.portfolio.loanaccount.guarantor.exception.InvalidGuar
|
||||
import org.apache.fineract.portfolio.loanaccount.guarantor.serialization.GuarantorCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -80,7 +79,6 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final AccountAssociationsRepository accountAssociationsRepository;
|
||||
private final GuarantorDomainService guarantorDomainService;
|
||||
private final RecurringDepositLienService recurringDepositLienService;
|
||||
|
||||
@Autowired
|
||||
public GuarantorWritePlatformServiceJpaRepositoryIImpl(final LoanRepositoryWrapper loanRepositoryWrapper,
|
||||
@@ -88,9 +86,7 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
|
||||
final StaffRepositoryWrapper staffRepositoryWrapper, final GroupRepositoryWrapper groupRepositoryWrapper,
|
||||
final GuarantorCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
final CodeValueRepositoryWrapper codeValueRepositoryWrapper, final SavingsAccountAssembler savingsAccountAssembler,
|
||||
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService,
|
||||
final RecurringDepositLienService recurringDepositLienService) {
|
||||
this.recurringDepositLienService = recurringDepositLienService;
|
||||
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService) {
|
||||
this.loanRepositoryWrapper = loanRepositoryWrapper;
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.groupRepositoryWrapper = groupRepositoryWrapper;
|
||||
@@ -124,9 +120,6 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
|
||||
final SavingsAccount savingsAccount = this.savingsAccountAssembler.assembleFrom(guarantorCommand.getSavingsId(),
|
||||
backdatedTxnsAllowedTill);
|
||||
validateGuarantorSavingsAccountActivationDateWithLoanSubmittedOnDate(loan, savingsAccount);
|
||||
// A recurring deposit pledged here is a lien on it: its product must allow that, and it can
|
||||
// never secure more than the balance it has not already pledged.
|
||||
this.recurringDepositLienService.validatePledge(savingsAccount, guarantorCommand.getAmount(), loan.getId());
|
||||
accountAssociations = AccountAssociations.associateSavingsAccount(loan, savingsAccount,
|
||||
AccountAssociationType.GUARANTOR_ACCOUNT_ASSOCIATION.getValue(), backdatedTxnsAllowedTill);
|
||||
|
||||
|
||||
+6
-10
@@ -71,11 +71,9 @@ public class RbiReserveConfigApiResource {
|
||||
static {
|
||||
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
|
||||
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
|
||||
// BALANCE_WITH_OTHER_BANKS is deliberately not offered. It belongs to the older working
|
||||
// report, and the statutory Form 1 this bank files has no line for it: balances with other
|
||||
// banks are reported there under III(a), III(b)(i) or VI, depending on which bank holds
|
||||
// them. Left on the screen it invited a mapping that would raise item X without appearing
|
||||
// anywhere on the form to say where the figure came from.
|
||||
ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS",
|
||||
"Current account balances the bank holds with other banks. Counts toward CRR maintained. "
|
||||
+ "Pin every such account - a bank typically banks with several.");
|
||||
ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES",
|
||||
"Deposit liabilities forming the DTL base that CRR and SLR are charged on. "
|
||||
+ "Pin the deposit accounts and any interest accrued on them.");
|
||||
@@ -212,14 +210,12 @@ public class RbiReserveConfigApiResource {
|
||||
ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter");
|
||||
} else {
|
||||
ROLE_GROUPS.put(role, "RESERVE");
|
||||
// Both ratios are settled on Form 1 - Part B is section 18, Part C section 24 - so
|
||||
// the part is named rather than a separate return that does not exist.
|
||||
if ("SLR_INVESTMENTS".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 1 Part C (SLR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
|
||||
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 1 — base for both ratios");
|
||||
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
|
||||
} else {
|
||||
ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
-319
@@ -1084,15 +1084,6 @@ public class RbiReserveReturnApiResource {
|
||||
*/
|
||||
private static LocalDate requirePastOrPresent(final LocalDate date, final String what) {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
// A year the ledger could not hold. A date field hands over whatever has been typed so far,
|
||||
// so a half-typed year arrives as a real date - "0026-09-30" parses cleanly - and the return
|
||||
// then reads a period no bank has entries for and reports every line nil.
|
||||
if (date.getYear() < 1900) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.year.incomplete",
|
||||
what + " (" + date + ") has a year below 1900, which looks like a date still being "
|
||||
+ "typed rather than a period to report.",
|
||||
what, date.toString());
|
||||
}
|
||||
if (date.isAfter(today)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.future.date",
|
||||
what + " (" + date + ") is in the future; the bank's business date is " + today
|
||||
@@ -1139,8 +1130,6 @@ public class RbiReserveReturnApiResource {
|
||||
public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam,
|
||||
@QueryParam("firstColumnDate") final String firstColumnDateParam,
|
||||
@QueryParam("secondColumnDate") final String secondColumnDateParam,
|
||||
@QueryParam("firstBaseDate") final String firstBaseDateParam,
|
||||
@QueryParam("secondBaseDate") final String secondBaseDateParam,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
@@ -1185,51 +1174,15 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
// The third column is the base the current fortnight's ratios are charged on - the last
|
||||
// matching close one month earlier - not the reporting date.
|
||||
// Friday of the second preceding fortnight - not the reporting date.
|
||||
//
|
||||
// It was the reporting date. Since the return is now made up to the second column, that
|
||||
// made the third column a copy of the second: two identically dated columns carrying
|
||||
// identical figures, which reads as a filled form and says nothing. The base belongs on the
|
||||
// face of the form because every required amount below is computed from it, and a reviewer
|
||||
// cannot check a requirement whose base is not stated.
|
||||
// Each close has its own base, and either can be given outright.
|
||||
//
|
||||
// Derived, the base is the matching close one month earlier, which is
|
||||
// what the rules fix it at. But a bank migrating onto the system, or filing a corrected
|
||||
// return for a period whose base fell before its ledger starts, has a base date that the
|
||||
// rule produces and the ledger cannot answer - and the ratio then reads nil against real
|
||||
// liabilities. Given explicitly, the date entered is used and stated on the form, so a
|
||||
// reader can see which base each column was charged on.
|
||||
final LocalDate previousBase = firstBaseDateParam == null || firstBaseDateParam.isBlank()
|
||||
? formOneBaseDate(previousClose)
|
||||
: requirePastOrPresent(LocalDate.parse(firstBaseDateParam), "The first column's base date");
|
||||
final LocalDate currentBase = secondBaseDateParam == null || secondBaseDateParam.isBlank()
|
||||
? formOneBaseDate(currentClose)
|
||||
: requirePastOrPresent(LocalDate.parse(secondBaseDateParam), "The second column's base date");
|
||||
|
||||
// A base dated after the close it serves would charge the ratio on liabilities the bank had
|
||||
// not yet taken on - the figure would be arithmetically fine and mean nothing.
|
||||
if (previousBase.isAfter(previousClose)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close",
|
||||
"The first column's base date (" + previousBase + ") falls after the close it is charged on ("
|
||||
+ previousClose + ").",
|
||||
previousBase.toString(), previousClose.toString());
|
||||
}
|
||||
if (currentBase.isAfter(currentClose)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close",
|
||||
"The second column's base date (" + currentBase + ") falls after the close it is charged on ("
|
||||
+ currentClose + ").",
|
||||
currentBase.toString(), currentClose.toString());
|
||||
}
|
||||
|
||||
// Two columns, one per fortnight close. The base dates are not columns of their own: they
|
||||
// are stated against the lines they settle, and a third column headed with one printed the
|
||||
// base's own balances beside the closes', which read as a third period the bank was
|
||||
// reporting rather than as the figures the ratios were charged on.
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose);
|
||||
// One base per column, in the same order, so each column is charged on its own base rather
|
||||
// than both on one.
|
||||
final List<LocalDate> baseDates = List.of(previousBase, currentBase);
|
||||
final LocalDate currentBase = formOneBaseDate(currentClose);
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose, currentBase);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("formCode", "FORM_1_STATUTORY");
|
||||
@@ -1237,9 +1190,6 @@ public class RbiReserveReturnApiResource {
|
||||
response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966");
|
||||
response.put("reportingDate", reportingDate.toString());
|
||||
response.put("columns", columnDates.stream().map(LocalDate::toString).toList());
|
||||
// Stated beside the columns so a reader can see which date each ratio was charged on,
|
||||
// without the base needing a column of its own.
|
||||
response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList());
|
||||
|
||||
final Rate crr = rateInForce("CRR", reportingDate);
|
||||
final Rate slr = rateInForce("SLR", reportingDate);
|
||||
@@ -1247,14 +1197,10 @@ public class RbiReserveReturnApiResource {
|
||||
response.put("slrPercentage", slr == null ? null : slr.percentage);
|
||||
|
||||
final List<Map<String, Object>> parts = new ArrayList<>();
|
||||
parts.add(formOnePartA(columnDates, baseDates, officeId, crr, slr));
|
||||
parts.add(formOnePartA(columnDates, officeId, crr, slr));
|
||||
response.put("partA", parts.get(0));
|
||||
response.put("partB", formOnePartB(columnDates, baseDates, officeId, crr));
|
||||
response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr));
|
||||
|
||||
// Every day of the month the closes fall in, charged on each day's own base. The form
|
||||
// reports two closes; compliance turns on every day between them.
|
||||
response.put("daily", formOneDaily(previousClose.withDayOfMonth(1), currentClose, officeId, crr, slr));
|
||||
response.put("partB", formOnePartB(columnDates, officeId, crr));
|
||||
response.put("partC", formOnePartC(columnDates, officeId, slr));
|
||||
|
||||
final List<String> unmet = new ArrayList<>();
|
||||
if (crr == null) {
|
||||
@@ -1273,8 +1219,9 @@ public class RbiReserveReturnApiResource {
|
||||
final BigDecimal baseLiabilities = formOneNetLiabilities(currentBase, officeId);
|
||||
if (baseLiabilities.compareTo(BigDecimal.ZERO) == 0) {
|
||||
unmet.add("Both ratios are charged on the net liabilities of " + currentBase
|
||||
+ ", and the ledger holds none on that date. The required amounts are therefore "
|
||||
+ "nil — nil because the base is nil, not because nothing is required.");
|
||||
+ " — the last Friday of the second preceding fortnight — and the ledger holds "
|
||||
+ "none on that date. The required amounts are therefore nil. They are nil "
|
||||
+ "because the base is nil, not because nothing is required.");
|
||||
}
|
||||
|
||||
response.put("unmetRequirements", unmet);
|
||||
@@ -1282,26 +1229,20 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
/** Part A: the liabilities and assets the two ratios are worked out from. */
|
||||
private Map<String, Object> formOnePartA(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate crr,
|
||||
private Map<String, Object> formOnePartA(final List<LocalDate> dates, final Long officeId, final Rate crr,
|
||||
final Rate slr) {
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
|
||||
// The labels below are the printed form's own wording, kept word for word. A shortened line
|
||||
// reads more easily on screen but this form is filed and signed, and a reader checking it
|
||||
// against the paper return has to find each line by its text - a paraphrase makes them stop
|
||||
// and work out whether it is the same line.
|
||||
lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM :"));
|
||||
lines.add(formOneHeading("(a) Demand Liabilities:"));
|
||||
final List<BigDecimal> iai = formOneLine(lines,
|
||||
"(i) Total of credit balances in current accounts maintained with the co-operative bank by State Bank of India,"
|
||||
+ " subsidiary banks and corresponding new banks.",
|
||||
lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM"));
|
||||
lines.add(formOneHeading("(a) Demand Liabilities"));
|
||||
final List<BigDecimal> iai = formOneLine(lines, "(i) Current account balances of SBI, its subsidiaries and the corresponding new banks",
|
||||
"FORM1_I_A_I_SBI_CURRENT", dates, officeId, 2);
|
||||
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Total of other demand liabilites to the banking system",
|
||||
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system",
|
||||
"FORM1_I_A_II_OTHER_DEMAND_BS", dates, officeId, 2);
|
||||
final List<BigDecimal> ib = formOneLine(lines, "(b) Time Liabilities to the banking system", "FORM1_I_B_TIME_BS",
|
||||
final List<BigDecimal> ib = formOneLine(lines, "(b) Time liabilities to the banking system", "FORM1_I_B_TIME_BS",
|
||||
dates, officeId, 1);
|
||||
final List<BigDecimal> totalI = sumOf(iai, iaii, ib);
|
||||
lines.add(formOneTotal("TOTAL OF I : >>>>>>>>>>>", totalI));
|
||||
lines.add(formOneTotal("TOTAL OF I", totalI));
|
||||
|
||||
lines.add(formOneHeading("II. LIABILITIES IN INDIA TO OTHERS"));
|
||||
// Demand and time come from the one DTL head, split by the class each mapped account
|
||||
@@ -1313,25 +1254,23 @@ public class RbiReserveReturnApiResource {
|
||||
iiA.add(dtl.demandAmount);
|
||||
iiB.add(dtl.timeAmount);
|
||||
}
|
||||
lines.add(formOneRow("(A) DEMAND LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiA, 1));
|
||||
lines.add(formOneRow("(B) TIME LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiB, 1));
|
||||
lines.add(formOneRow("(A) Demand liabilities", "DEMAND_TIME_LIABILITIES", iiA, 1));
|
||||
lines.add(formOneRow("(B) Time liabilities", "DEMAND_TIME_LIABILITIES", iiB, 1));
|
||||
final List<BigDecimal> totalII = sumOf(iiA, iiB);
|
||||
lines.add(formOneTotal("TOTAL OF II : >>>>>>>>>>>", totalII));
|
||||
lines.add(formOneTotal("TOTAL OF II", totalII));
|
||||
|
||||
lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM"));
|
||||
final List<BigDecimal> iiiA = formOneLine(lines,
|
||||
"(A) TOTAL OF CREDIT BALANCES IN CURRENT ACCOUNT maintained with S.B.I, Subsidiary Banks & corresponding new banks",
|
||||
final List<BigDecimal> iiiA = formOneLine(lines, "(A) Current account balances with SBI, its subsidiaries and the corresponding new banks",
|
||||
"FORM1_III_A_SBI_CURRENT", dates, officeId, 1);
|
||||
lines.add(formOneHeading("(B) TOTAL OF OTHER ASSETS WITH THE BANKING SYSTEM, VIZ."));
|
||||
final List<BigDecimal> iiiBi = formOneLine(lines,
|
||||
"(i) Balances in all Accounts other than those included in item III (a) :",
|
||||
lines.add(formOneHeading("(B) Other assets with the banking system"));
|
||||
final List<BigDecimal> iiiBi = formOneLine(lines, "(i) Balances in all accounts other than III(A)",
|
||||
"FORM1_III_B_I_OTHER_BALANCES", dates, officeId, 2);
|
||||
final List<BigDecimal> iiiBii = formOneLine(lines, "(ii) Money at Call and Short notice :",
|
||||
final List<BigDecimal> iiiBii = formOneLine(lines, "(ii) Money at call and short notice",
|
||||
"FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2);
|
||||
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances & any other Assets :",
|
||||
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances and any other assets",
|
||||
"FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2);
|
||||
final List<BigDecimal> totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv);
|
||||
lines.add(formOneTotal("TOTAL OF III : >>>>>>>>>>", totalIII));
|
||||
lines.add(formOneTotal("TOTAL OF III", totalIII));
|
||||
|
||||
// IV: the banking system nets out, and what is left is added to the liabilities to others.
|
||||
// Floored at zero per column - the form's own wording, "(I - III) + II if minus figure",
|
||||
@@ -1340,54 +1279,29 @@ public class RbiReserveReturnApiResource {
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
iv.add(totalI.get(i).subtract(totalIII.get(i)).max(BigDecimal.ZERO).add(totalII.get(i)));
|
||||
}
|
||||
// The form carries IV twice, and the difference between the two is the whole of how the
|
||||
// ratios work. (a) is this column's own liabilities - what the bank owed on the close being
|
||||
// reported. (b) is the base: the same figure read at the earlier close, and the only one the
|
||||
// requirement is actually charged on. A form showing one IV reads as though the bank had to
|
||||
// hold a ratio of today's liabilities, which is not what either section says.
|
||||
lines.add(formOneTotal(
|
||||
"IV. a) TOTAL (NET) DEMAND & TIME LIABILITIES FOR THE PURPOSE OF SEC.18 & 24 OF THE ACT = (I-III)+II IF MINUS"
|
||||
+ " FIGURE [CURRENT FORTNIGHT]",
|
||||
iv));
|
||||
final List<BigDecimal> ivBase = new ArrayList<>();
|
||||
final List<String> ivBaseDates = new ArrayList<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
final LocalDate base = baseFor(bases, dates, i);
|
||||
ivBaseDates.add(base.toString());
|
||||
ivBase.add(formOneNetLiabilities(base, officeId));
|
||||
}
|
||||
// The printed form words this "AS ON THE LAST FRIDAY OF THE SECOND PRECEDING FORTNIGHT",
|
||||
// which is the Act's rule. This bank does not file on that rule - its base is the matching
|
||||
// fortnight close one month earlier, which formOneBaseDate computes - so the line says BASE
|
||||
// DATE and leaves the dates themselves to the column headings. A heading that named a
|
||||
// Friday the return never used would be a statement nobody could check.
|
||||
final Map<String, Object> ivBaseRow = formOneTotal(
|
||||
"IV. b) DEMAND AND TIME LIABILITY OF BASE DATE FOR THE PURPOSE OF SEC.18 & 24",
|
||||
ivBase);
|
||||
ivBaseRow.put("baseDates", ivBaseDates);
|
||||
lines.add(ivBaseRow);
|
||||
lines.add(formOneTotal("IV. TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24", iv));
|
||||
|
||||
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND :", "CASH_IN_HAND", dates, officeId, 0);
|
||||
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0);
|
||||
|
||||
lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH :"));
|
||||
final List<BigDecimal> viA = formOneLine(lines, "(a) Reserve Bank of India :", "FORM1_VI_A_RBI_CURRENT", dates,
|
||||
lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH"));
|
||||
final List<BigDecimal> viA = formOneLine(lines, "(a) Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", dates,
|
||||
officeId, 1);
|
||||
final List<BigDecimal> viB = formOneLine(lines, "(b) State Co.Op.Bank of the State Concerned :",
|
||||
final List<BigDecimal> viB = formOneLine(lines, "(b) State Co-operative Bank of the state concerned",
|
||||
"FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1);
|
||||
final List<BigDecimal> viC = formOneLine(lines, "(c) Central Co.Op.Bank of the Dist.Concerned :",
|
||||
final List<BigDecimal> viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned",
|
||||
"FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1);
|
||||
final List<BigDecimal> totalVI = sumOf(viA, viB, viC);
|
||||
lines.add(formOneTotal("TOTAL OF VI : >>>>>>>>>>", totalVI));
|
||||
lines.add(formOneTotal("TOTAL OF VI", totalVI));
|
||||
|
||||
lines.add(formOneHeading("VII. Balance Of all Other types With :"));
|
||||
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co.Op.Bank of the State Concerned :",
|
||||
lines.add(formOneHeading("VII. BALANCES OF ALL OTHER TYPES WITH"));
|
||||
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned",
|
||||
"FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1);
|
||||
final List<BigDecimal> viiB = formOneLine(lines, "(B) CENTRAL CO.OP.BANK OF THE DIST.CONCERNED",
|
||||
final List<BigDecimal> viiB = formOneLine(lines, "(b) District Central Co-operative Bank",
|
||||
"FORM1_VII_B_DCCB_OTHER", dates, officeId, 1);
|
||||
final List<BigDecimal> viiC = formOneLine(lines, "(C) INVESTMENT OF GOVT. SECURITY",
|
||||
final List<BigDecimal> viiC = formOneLine(lines, "(c) Investment in Government securities",
|
||||
"FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1);
|
||||
final List<BigDecimal> totalVII = sumOf(viiA, viiB, viiC);
|
||||
lines.add(formOneTotal("TOTAL OF VII : >>>>>>>>>>", totalVII));
|
||||
lines.add(formOneTotal("TOTAL OF VII", totalVII));
|
||||
|
||||
// VIII: only the excess counts. Where the banking system's claim on this bank exceeds the
|
||||
// bank's own balance there is nothing to carry into the cash reserve, so it floors at zero.
|
||||
@@ -1395,27 +1309,39 @@ public class RbiReserveReturnApiResource {
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
viii.add(iiiA.get(i).subtract(iai.get(i)).max(BigDecimal.ZERO));
|
||||
}
|
||||
lines.add(formOneTotal("VIII. Net balance in current accounts i.e.excess of III (a) over I (a) (i) :", viii));
|
||||
lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii));
|
||||
|
||||
// The liabilities the ratios are charged on: the last Friday of the second preceding
|
||||
// fortnight, read per column rather than once, so each column states its own base.
|
||||
final List<BigDecimal> baseDateDtl = new ArrayList<>();
|
||||
final List<String> baseDates = new ArrayList<>();
|
||||
for (final LocalDate date : dates) {
|
||||
final LocalDate base = formOneBaseDate(date);
|
||||
baseDates.add(base.toString());
|
||||
baseDateDtl.add(formOneNetLiabilities(base, officeId));
|
||||
}
|
||||
final Map<String, Object> baseRow = formOneTotal(
|
||||
"DEMAND AND TIME LIABILITIES OF THE BASE DATE — last Friday of the second preceding fortnight",
|
||||
baseDateDtl);
|
||||
baseRow.put("baseDates", baseDates);
|
||||
lines.add(baseRow);
|
||||
|
||||
// The base liabilities are IV(b) above - it used to be repeated as a row of its own at the
|
||||
// foot of the part, which put the same figure on the form twice under two different names.
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("title", "PART A");
|
||||
part.put("lines", lines);
|
||||
part.put("baseDates", ivBaseDates);
|
||||
part.put("baseDateLiabilities", ivBase);
|
||||
part.put("baseDates", baseDates);
|
||||
part.put("baseDateLiabilities", baseDateDtl);
|
||||
return part;
|
||||
}
|
||||
|
||||
/** Part B: compliance with section 18 — the cash reserve. */
|
||||
private Map<String, Object> formOnePartB(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate crr) {
|
||||
private Map<String, Object> formOnePartB(final List<LocalDate> dates, final Long officeId, final Rate crr) {
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
final List<BigDecimal> required = new ArrayList<>();
|
||||
final List<BigDecimal> maintained = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
final LocalDate date = dates.get(i);
|
||||
final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId);
|
||||
for (final LocalDate date : dates) {
|
||||
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId);
|
||||
required.add(crr == null ? BigDecimal.ZERO
|
||||
: base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP));
|
||||
// X = V + VI + VIII, the form's own definition.
|
||||
@@ -1428,16 +1354,12 @@ public class RbiReserveReturnApiResource {
|
||||
maintained.add(cash.add(currentAccounts).add(netCurrent));
|
||||
}
|
||||
|
||||
// The rate is named against IV(b), not IV, because that is the figure it is charged on. The
|
||||
// template's own wording says "Last Friday of the Second Preceding fortnight"; this bank's
|
||||
// base rule is the matching close of the previous month, so the line says IV(b) and leaves
|
||||
// the base date itself to the column heading rather than claiming a Friday it did not use.
|
||||
lines.add(formOneTotal("IX. " + (crr == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE",
|
||||
required));
|
||||
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED := V+VI+VIII", maintained));
|
||||
lines.add(formOneTotal("IX. " + (crr == null ? "CRR" : crr.percentage + "%")
|
||||
+ " of IV as on the last Friday of the second preceding fortnight", required));
|
||||
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained));
|
||||
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("title", "PART B : COMPLIANCE WITH SECTION 18. (Not applicable to scheduled state co-op. Bank)");
|
||||
part.put("title", "PART B — Compliance with Section 18");
|
||||
part.put("lines", lines);
|
||||
part.put("required", required);
|
||||
part.put("maintained", maintained);
|
||||
@@ -1446,49 +1368,44 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
/** Part C: compliance with section 24 — the liquid assets. */
|
||||
private Map<String, Object> formOnePartC(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate slr) {
|
||||
private Map<String, Object> formOnePartC(final List<LocalDate> dates, final Long officeId, final Rate slr) {
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
final List<BigDecimal> required = new ArrayList<>();
|
||||
final List<BigDecimal> cashAndOther = new ArrayList<>();
|
||||
final List<BigDecimal> gold = new ArrayList<>();
|
||||
final List<BigDecimal> securities = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
final LocalDate date = dates.get(i);
|
||||
final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId);
|
||||
for (final LocalDate date : dates) {
|
||||
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId);
|
||||
final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
|
||||
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
required.add(slrRequired);
|
||||
|
||||
// XII(a) is the cash reserve held OVER the section 18 requirement, plus the government
|
||||
// securities of VII(c). Only the excess cash counts: the part of the cash reserve that
|
||||
// meets section 18 is already committed and cannot also meet section 24.
|
||||
//
|
||||
// VII(c) alone, not the whole of VII. VII(a) and VII(b) are balances with the State
|
||||
// Co-operative Bank and the District Central Co-operative Bank - deposits placed with
|
||||
// other co-operative banks, which section 24 does not admit as liquid assets. Counting
|
||||
// them here overstated what the bank held against SLR by the whole of those deposits.
|
||||
// XII(a) is the cash reserve held OVER the section 18 requirement, plus the other
|
||||
// balances of part VII. Only the excess counts: the part of the cash reserve that meets
|
||||
// section 18 is already committed and cannot also meet section 24.
|
||||
final BigDecimal crrRequired = formOnePartBRequired(date, officeId);
|
||||
final BigDecimal crrMaintained = formOnePartBMaintained(date, officeId);
|
||||
final BigDecimal excessCash = crrMaintained.subtract(crrRequired).max(BigDecimal.ZERO);
|
||||
final BigDecimal governmentSecurities = figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount;
|
||||
cashAndOther.add(excessCash.add(governmentSecurities));
|
||||
final BigDecimal otherBalances = figureFor("FORM1_VII_A_STATE_COOP_OTHER", date, officeId).amount
|
||||
.add(figureFor("FORM1_VII_B_DCCB_OTHER", date, officeId).amount)
|
||||
.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount);
|
||||
cashAndOther.add(excessCash.add(otherBalances));
|
||||
gold.add(figureFor("FORMA_SLR_GOLD", date, officeId).amount);
|
||||
securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount);
|
||||
}
|
||||
|
||||
// Charged on IV(b) like the cash reserve above, and named the same way.
|
||||
lines.add(formOneTotal("XI. " + (slr == null ? "SLR rate not set" : slr.percentage + " percent") + " (or a higher specified percentage) of IV as on the base date",
|
||||
required));
|
||||
lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED :"));
|
||||
lines.add(formOneRow("(a) Cash and other balances maintained in India i.e. X-IX+VII :", null, cashAndOther, 1));
|
||||
lines.add(formOneTotal("XI. " + (slr == null ? "SLR" : slr.percentage + "%")
|
||||
+ " of IV as on the last Friday of the second preceding fortnight", required));
|
||||
lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED"));
|
||||
lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1));
|
||||
lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1));
|
||||
lines.add(formOneRow("(c) Unencombered approved Securites $$", "SLR_INVESTMENTS", securities, 1));
|
||||
lines.add(formOneRow("(c) Unencumbered approved securities", "SLR_INVESTMENTS", securities, 1));
|
||||
final List<BigDecimal> totalXII = sumOf(cashAndOther, gold, securities);
|
||||
lines.add(formOneTotal("Total of XII.....................................", totalXII));
|
||||
lines.add(formOneTotal("TOTAL OF XII", totalXII));
|
||||
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("title", "PART C : COMPLIANCE WITH SECTION 24 (Not applicable to scheduled state co-op. banks)");
|
||||
part.put("title", "PART C — Compliance with Section 24");
|
||||
part.put("lines", lines);
|
||||
part.put("required", required);
|
||||
part.put("maintained", totalXII);
|
||||
@@ -1496,115 +1413,6 @@ public class RbiReserveReturnApiResource {
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day of the period the form covers, with what each ratio required that day and what was
|
||||
* held against it.
|
||||
*
|
||||
* <p>
|
||||
* The form itself reports two closes, and a bank that held nothing for thirteen days and topped
|
||||
* up on the fourteenth has a form that reads as compliant. Both ratios are reckoned over the
|
||||
* whole period, so the day-by-day position is what the compliance actually turns on - and it is
|
||||
* the figure an inspector asks for first.
|
||||
*
|
||||
* <p>
|
||||
* Each day is charged on ITS OWN base, read through the same rule the columns use, so a day in
|
||||
* the first fortnight is measured against a different requirement from one in the second. A
|
||||
* single required amount applied across the month would show every day of one fortnight passing
|
||||
* or failing for the wrong reason.
|
||||
*
|
||||
* @param from the first day reported, usually the start of the month the closes fall in.
|
||||
* @param to the last, which is the later close.
|
||||
*/
|
||||
private Map<String, Object> formOneDaily(final LocalDate from, final LocalDate to, final Long officeId,
|
||||
final Rate crr, final Rate slr) {
|
||||
final List<Map<String, Object>> days = new ArrayList<>();
|
||||
BigDecimal crrTotal = BigDecimal.ZERO;
|
||||
BigDecimal slrTotal = BigDecimal.ZERO;
|
||||
BigDecimal crrLowest = null;
|
||||
BigDecimal slrLowest = null;
|
||||
LocalDate crrLowestOn = null;
|
||||
LocalDate slrLowestOn = null;
|
||||
int crrShortDays = 0;
|
||||
int slrShortDays = 0;
|
||||
|
||||
for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) {
|
||||
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(day), officeId);
|
||||
final BigDecimal crrRequired = crr == null ? BigDecimal.ZERO
|
||||
: base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
|
||||
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
|
||||
final BigDecimal crrHeld = formOnePartBMaintained(day, officeId);
|
||||
// SLR counts the cash held OVER the section 18 requirement, plus the other balances and
|
||||
// the securities - the same definition Part C uses, so the two cannot disagree.
|
||||
final BigDecimal excessCash = crrHeld.subtract(crrRequired).max(BigDecimal.ZERO);
|
||||
final BigDecimal slrHeld = excessCash
|
||||
.add(figureFor("FORM1_VII_A_STATE_COOP_OTHER", day, officeId).amount)
|
||||
.add(figureFor("FORM1_VII_B_DCCB_OTHER", day, officeId).amount)
|
||||
.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", day, officeId).amount)
|
||||
.add(figureFor("FORMA_SLR_GOLD", day, officeId).amount)
|
||||
.add(figureFor("SLR_INVESTMENTS", day, officeId).amount);
|
||||
|
||||
final BigDecimal crrSurplus = crrHeld.subtract(crrRequired);
|
||||
final BigDecimal slrSurplus = slrHeld.subtract(slrRequired);
|
||||
if (crrSurplus.compareTo(BigDecimal.ZERO) < 0) {
|
||||
crrShortDays++;
|
||||
}
|
||||
if (slrSurplus.compareTo(BigDecimal.ZERO) < 0) {
|
||||
slrShortDays++;
|
||||
}
|
||||
|
||||
crrTotal = crrTotal.add(crrHeld);
|
||||
slrTotal = slrTotal.add(slrHeld);
|
||||
if (crrLowest == null || crrHeld.compareTo(crrLowest) < 0) {
|
||||
crrLowest = crrHeld;
|
||||
crrLowestOn = day;
|
||||
}
|
||||
if (slrLowest == null || slrHeld.compareTo(slrLowest) < 0) {
|
||||
slrLowest = slrHeld;
|
||||
slrLowestOn = day;
|
||||
}
|
||||
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("date", day.toString());
|
||||
row.put("baseDate", formOneBaseDate(day).toString());
|
||||
// The liabilities the day was charged on, stated rather than left to be divided back out
|
||||
// of the requirement: the CIMS daily sheets carry it as a column of its own, and on a day
|
||||
// whose rate is unset the requirement is zero, which no division recovers a base from.
|
||||
row.put("baseLiabilities", base);
|
||||
// The day's OWN liabilities, which the base above does not show. The base holds still
|
||||
// for a whole fortnight, so a table carrying only it cannot say whether the bank's
|
||||
// liabilities rose or fell on any particular day - every row of the fortnight reads the
|
||||
// same. This moves daily, and the gap between the two is the lag the rules build in.
|
||||
row.put("ownLiabilities", formOneNetLiabilities(day, officeId));
|
||||
row.put("crrRequired", crrRequired);
|
||||
row.put("crrMaintained", crrHeld);
|
||||
row.put("crrSurplus", crrSurplus);
|
||||
row.put("slrRequired", slrRequired);
|
||||
row.put("slrMaintained", slrHeld);
|
||||
row.put("slrSurplus", slrSurplus);
|
||||
days.add(row);
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("from", from.toString());
|
||||
out.put("to", to.toString());
|
||||
out.put("days", days);
|
||||
out.put("dayCount", days.size());
|
||||
final BigDecimal count = BigDecimal.valueOf(Math.max(days.size(), 1));
|
||||
out.put("crrAverage", crrTotal.divide(count, 2, RoundingMode.HALF_UP));
|
||||
out.put("slrAverage", slrTotal.divide(count, 2, RoundingMode.HALF_UP));
|
||||
out.put("crrLowest", crrLowest == null ? BigDecimal.ZERO : crrLowest);
|
||||
out.put("crrLowestOn", crrLowestOn == null ? null : crrLowestOn.toString());
|
||||
out.put("slrLowest", slrLowest == null ? BigDecimal.ZERO : slrLowest);
|
||||
out.put("slrLowestOn", slrLowestOn == null ? null : slrLowestOn.toString());
|
||||
// Counted rather than left for a reader to spot: a single day short is a breach, and on a
|
||||
// table of thirty rows it is exactly what the eye skips.
|
||||
out.put("crrShortDays", crrShortDays);
|
||||
out.put("slrShortDays", slrShortDays);
|
||||
return out;
|
||||
}
|
||||
|
||||
private BigDecimal formOnePartBRequired(final LocalDate date, final Long officeId) {
|
||||
final Rate crr = rateInForce("CRR", date);
|
||||
if (crr == null) {
|
||||
@@ -1625,47 +1433,23 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
/**
|
||||
* The base date a column's ratios are charged on.
|
||||
* The date both ratios are charged on: the last Friday of the SECOND PRECEDING fortnight.
|
||||
*
|
||||
* <p>
|
||||
* Taken from the list the caller supplied, so a base entered by hand reaches every part of the
|
||||
* form rather than only the column heading. Where no list was given - or it is shorter than the
|
||||
* columns - the statutory rule applies.
|
||||
*/
|
||||
private LocalDate baseFor(final List<LocalDate> bases, final List<LocalDate> dates, final int index) {
|
||||
if (bases != null && index < bases.size() && bases.get(index) != null) {
|
||||
return bases.get(index);
|
||||
}
|
||||
return formOneBaseDate(dates.get(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* The date a column's ratios are charged on: the previous month's matching fortnight close.
|
||||
* Counted in fortnights, not in weeks. For a column closing 15 August the fortnight it belongs
|
||||
* to is 1-15 August, the first preceding one is 16-31 July, and the second is 1-15 July - so
|
||||
* the base falls in the first half of July, roughly six weeks back rather than the four a
|
||||
* straight subtraction would give. Reading the rate off a date a fortnight too late would
|
||||
* report a requirement the bank never had time to meet, which is the whole point of the lag.
|
||||
*
|
||||
* <p>
|
||||
* Which fortnight the column falls in settles it, not the day it carries. The 1st to the 15th
|
||||
* charge on the 15th of the previous month; anything later charges on the previous month's END,
|
||||
* whatever day that is. So 30 September charges on 31 August - and so does 20 September, since
|
||||
* both sit in the second fortnight and the second fortnight closes with the month.
|
||||
*
|
||||
* <p>
|
||||
* Keyed on the fortnight rather than on the day number because the two closes are not the same
|
||||
* kind of date: the 15th is fixed, the month end moves between 28 and 31. Matching day for day
|
||||
* would charge 30 September on 30 August, a date that closes nothing.
|
||||
*
|
||||
* <p>
|
||||
* RBI's own wording is "the last Friday of the second preceding fortnight", and that rule has
|
||||
* been tried here twice. Both times it was taken out again for the same reason: it puts the base
|
||||
* on a weekday that moves and roughly six weeks back, and the dates it produces do not match the
|
||||
* returns this bank has already filed by hand. The fortnight closes this bank files on are the
|
||||
* 15th and the month end. Where a particular return needs some other base, it can be entered by
|
||||
* hand through {@code baseFor} above, which is why that path exists.
|
||||
* Within that fortnight the form takes its last Friday, shifted off a holiday the same way
|
||||
* every other reporting date is.
|
||||
*/
|
||||
private LocalDate formOneBaseDate(final LocalDate columnDate) {
|
||||
final LocalDate previousMonthEnd = columnDate.withDayOfMonth(1).minusDays(1);
|
||||
// Which fortnight the column falls in settles the base, not the day it carries. The first
|
||||
// fortnight closes on the 15th; everything after it closes on the month end.
|
||||
return columnDate.getDayOfMonth() <= 15 ? previousMonthEnd.withDayOfMonth(15) : previousMonthEnd;
|
||||
final LocalDate ownClose = fortnightCloseOnOrAfter(columnDate);
|
||||
final LocalDate secondPrecedingClose = fortnightCloseBefore(fortnightCloseBefore(ownClose));
|
||||
return precedingWorkingDay(reportingFridayOnOrBefore(secondPrecedingClose));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1795,10 +1579,7 @@ public class RbiReserveReturnApiResource {
|
||||
final LocalDate previousClose = requested.withDayOfMonth(1).minusDays(1);
|
||||
final LocalDate base = formOneBaseDate(currentClose);
|
||||
|
||||
// Two columns, as on Form 1. The base is stated against the lines it settles rather than
|
||||
// given a column, where its balances would read as a third period being reported.
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose);
|
||||
final List<LocalDate> baseDates = List.of(formOneBaseDate(previousClose), base);
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose, base);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("formCode", "FORM_9_STATUTORY");
|
||||
@@ -1809,7 +1590,6 @@ public class RbiReserveReturnApiResource {
|
||||
response.put("monthEnd", nominalMonthEnd.toString());
|
||||
response.put("monthComplete", monthComplete);
|
||||
response.put("columns", columnDates.stream().map(LocalDate::toString).toList());
|
||||
response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList());
|
||||
|
||||
final Rate crr = rateInForce("CRR", currentClose);
|
||||
final Rate slr = rateInForce("SLR", currentClose);
|
||||
@@ -1819,11 +1599,8 @@ public class RbiReserveReturnApiResource {
|
||||
// Part A establishes the liabilities both ratios are charged on, so it is needed here even
|
||||
// though only section 24 is settled below: without it the required amount at XI is a figure
|
||||
// with nothing behind it.
|
||||
response.put("partA", formOnePartA(columnDates, baseDates, officeId, crr, slr));
|
||||
response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr));
|
||||
// Every day of the month, as on Form 1. Section 24 is reckoned over the period too, so a
|
||||
// month-end position that passes says nothing about the days behind it.
|
||||
response.put("daily", formOneDaily(currentClose.withDayOfMonth(1), currentClose, officeId, crr, slr));
|
||||
response.put("partA", formOnePartA(columnDates, officeId, crr, slr));
|
||||
response.put("partC", formOnePartC(columnDates, officeId, slr));
|
||||
|
||||
final List<String> unmet = new ArrayList<>();
|
||||
if (slr == null) {
|
||||
@@ -1843,9 +1620,9 @@ public class RbiReserveReturnApiResource {
|
||||
// As on Form 1: a nil base makes the requirement nil, and the form then reads the same as a
|
||||
// bank with nothing to maintain. Said with the date so the nil can be read for what it is.
|
||||
if (formOneNetLiabilities(base, officeId).compareTo(BigDecimal.ZERO) == 0) {
|
||||
unmet.add("The ratio is charged on the net liabilities of " + base
|
||||
+ ", and the ledger holds none on that date. The required amount is therefore "
|
||||
+ "nil, because the base is nil.");
|
||||
unmet.add("The ratio is charged on the net liabilities of " + base + " — the last Friday "
|
||||
+ "of the second preceding fortnight — and the ledger holds none on that date. "
|
||||
+ "The required amount is therefore nil, because the base is nil.");
|
||||
}
|
||||
response.put("unmetRequirements", unmet);
|
||||
return gson.toJson(response);
|
||||
|
||||
+1
-3
@@ -95,9 +95,7 @@ public class RbiFormAAutoSaveService {
|
||||
// No column dates passed: today IS a fortnight close - the check above returned
|
||||
// otherwise - so the form derives its own pair from it, which is the pair an unattended
|
||||
// run should file. A filer needing a different period enters both dates on the screen.
|
||||
// No base dates passed either: an unattended run files the base the rules fix, which is
|
||||
// what a saved return should carry. A filer needing a different base enters it on screen.
|
||||
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null, null, null),
|
||||
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null),
|
||||
JsonObject.class);
|
||||
} catch (final RuntimeException e) {
|
||||
// A return that cannot be computed is not an error worth failing the batch over - an
|
||||
|
||||
+1
-4
@@ -361,9 +361,6 @@ public class RecurringDepositAccountsApiResource {
|
||||
} else if (is(commandParam, "close")) {
|
||||
final CommandWrapper commandRequest = builder.closeRecurringDepositAccount(accountId).build();
|
||||
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
} else if (is(commandParam, "extendTenure")) {
|
||||
final CommandWrapper commandRequest = builder.extendTenureRecurringDepositAccount(accountId).build();
|
||||
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
} else if (is(commandParam, "prematureClose")) {
|
||||
final CommandWrapper commandRequest = builder.prematureCloseRecurringDepositAccount(accountId).build();
|
||||
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
@@ -380,7 +377,7 @@ public class RecurringDepositAccountsApiResource {
|
||||
if (result == null) {
|
||||
throw new UnrecognizedQueryParamException("command", commandParam,
|
||||
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
|
||||
"postInterest", "close", "prematureClose", "calculatePrematureAmount", "extendTenure" });
|
||||
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
|
||||
}
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
|
||||
-205
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* RD Dashboard's data source: every Recurring Deposit still running (ACTIVE, or MATURED and waiting to be closed) in
|
||||
* the user's office hierarchy, each with its installment position worked out as of the business date, plus the
|
||||
* month's closures and penalty income for the summary cards.
|
||||
*
|
||||
* <p>
|
||||
* An installment counts as paid by the deposit's cumulative deposits, not by the schedule's own completed flag - the
|
||||
* same rule {@code RecurringDepositMissedInstallmentPenaltyService} uses, since money paid in advance at the counter is
|
||||
* not always allocated to the installment it covers. The dashboard and the penalty job therefore agree on which
|
||||
* installments are overdue.
|
||||
*/
|
||||
@Path("/v1/recurringdepositaccounts/dashboard")
|
||||
@Component
|
||||
@Tag(name = "Recurring Deposit Accounts", description = "RD Dashboard: running RDs with their installment position.")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositDashboardApiResource {
|
||||
|
||||
/** DepositAccountType.RECURRING_DEPOSIT. */
|
||||
private static final int RECURRING_DEPOSIT = 300;
|
||||
/** SavingsAccountStatusType values. */
|
||||
private static final int ACTIVE = 300;
|
||||
private static final int CLOSED = 600;
|
||||
private static final int PRE_MATURE_CLOSURE = 700;
|
||||
private static final int MATURED = 800;
|
||||
/** SavingsAccountTransactionType.DEPOSIT. */
|
||||
private static final int DEPOSIT = 1;
|
||||
|
||||
private static final String ROWS_SQL = """
|
||||
WITH rd AS (
|
||||
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.account_balance_derived,
|
||||
sa.auto_debit_facility, sa.linked_account_no, c.display_name AS client_name, o.id AS office_id,
|
||||
o.name AS office_name
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ?
|
||||
),
|
||||
paid AS (
|
||||
SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid,
|
||||
SUM(t.amount) FILTER (WHERE t.transaction_date BETWEEN ? AND ?) AS paid_this_month
|
||||
FROM m_savings_account_transaction t
|
||||
JOIN rd ON rd.id = t.savings_account_id
|
||||
WHERE t.transaction_type_enum = ? AND t.is_reversed = false
|
||||
GROUP BY t.savings_account_id
|
||||
),
|
||||
sched AS (
|
||||
SELECT s.savings_account_id AS id, s.duedate, s.deposit_amount,
|
||||
SUM(s.deposit_amount) OVER (PARTITION BY s.savings_account_id ORDER BY s.duedate, s.installment) AS cum_due
|
||||
FROM m_mandatory_savings_schedule s
|
||||
JOIN rd ON rd.id = s.savings_account_id
|
||||
),
|
||||
pos AS (
|
||||
SELECT sc.id,
|
||||
COUNT(*) AS total_installments,
|
||||
COUNT(*) FILTER (WHERE sc.cum_due <= COALESCE(p.total_paid, 0)) AS paid_installments,
|
||||
MIN(sc.duedate) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0)) AS next_unpaid_due,
|
||||
COUNT(*) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0) AND sc.duedate < ?) AS overdue_installments,
|
||||
GREATEST(COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate < ?), 0) - COALESCE(MAX(p.total_paid), 0), 0)
|
||||
AS overdue_amount,
|
||||
COUNT(*) FILTER (WHERE sc.duedate = ?) AS due_today_count,
|
||||
COUNT(*) FILTER (WHERE sc.duedate = ? AND sc.cum_due <= COALESCE(p.total_paid, 0)) AS due_today_paid,
|
||||
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?), 0) AS due_this_month,
|
||||
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?
|
||||
AND sc.cum_due <= COALESCE(p.total_paid, 0)), 0) AS received_this_month
|
||||
FROM sched sc
|
||||
LEFT JOIN paid p ON p.id = sc.id
|
||||
GROUP BY sc.id
|
||||
),
|
||||
pen AS (
|
||||
SELECT pe.savings_account_id AS id, COUNT(*) AS penalty_count, SUM(pe.penalty_amount) AS penalty_amount
|
||||
FROM m_rd_installment_penalty pe
|
||||
JOIN rd ON rd.id = pe.savings_account_id
|
||||
GROUP BY pe.savings_account_id
|
||||
),
|
||||
lien AS (
|
||||
SELECT paa.linked_savings_account_id AS id, SUM(COALESCE(gfd.amount_remaining_derived, gfd.amount)) AS lien_amount
|
||||
FROM m_guarantor_funding_details gfd
|
||||
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
|
||||
JOIN m_loan l ON l.id = paa.loan_account_id
|
||||
JOIN rd ON rd.id = paa.linked_savings_account_id
|
||||
WHERE gfd.status_enum = 100 AND l.loan_status_id IN (100, 200, 300)
|
||||
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
|
||||
GROUP BY paa.linked_savings_account_id
|
||||
)
|
||||
SELECT rd.id AS "id", rd.account_no AS "accountNo", rd.client_id AS "clientId", rd.client_name AS "clientName",
|
||||
rd.office_id AS "officeId", rd.office_name AS "officeName", sp.id AS "productId", sp.name AS "productName",
|
||||
rd.status_enum AS "status", rd.account_balance_derived AS "balance",
|
||||
rdd.mandatory_recommended_deposit_amount AS "installmentAmount",
|
||||
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate", datp.maturity_amount AS "maturityAmount",
|
||||
COALESCE(rd.auto_debit_facility, false) AS "autoDebit", rd.linked_account_no AS "linkedAccountNo",
|
||||
COALESCE(sp.missed_installment_grace_days, 0) AS "graceDays",
|
||||
COALESCE(sp.penalty_grace_period_missed_installments, false) AS "penaltyEnabled",
|
||||
COALESCE(pos.total_installments, 0) AS "totalInstallments",
|
||||
COALESCE(pos.paid_installments, 0) AS "paidInstallments",
|
||||
TO_CHAR(pos.next_unpaid_due, 'YYYY-MM-DD') AS "nextDueDate",
|
||||
COALESCE(pos.overdue_installments, 0) AS "overdueInstallments",
|
||||
COALESCE(pos.overdue_amount, 0) AS "overdueAmount",
|
||||
COALESCE(pos.due_today_count, 0) AS "dueTodayCount",
|
||||
COALESCE(pos.due_today_paid, 0) AS "dueTodayPaid",
|
||||
COALESCE(pos.due_this_month, 0) AS "dueThisMonth",
|
||||
COALESCE(pos.received_this_month, 0) AS "receivedThisMonth",
|
||||
COALESCE(p.paid_this_month, 0) AS "paidThisMonth",
|
||||
COALESCE(pen.penalty_count, 0) AS "penaltyCount",
|
||||
COALESCE(pen.penalty_amount, 0) AS "penaltyAmount",
|
||||
COALESCE(lien.lien_amount, 0) AS "lienAmount"
|
||||
FROM rd
|
||||
JOIN m_savings_product sp ON sp.id = rd.product_id
|
||||
LEFT JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = rd.id
|
||||
LEFT JOIN m_deposit_account_recurring_detail rdd ON rdd.savings_account_id = rd.id
|
||||
LEFT JOIN pos ON pos.id = rd.id
|
||||
LEFT JOIN paid p ON p.id = rd.id
|
||||
LEFT JOIN pen ON pen.id = rd.id
|
||||
LEFT JOIN lien ON lien.id = rd.id
|
||||
ORDER BY pos.next_unpaid_due NULLS LAST, rd.account_no
|
||||
""";
|
||||
|
||||
/** RDs closed this month, split by how they ended. */
|
||||
private static final String CLOSED_THIS_MONTH_SQL = """
|
||||
SELECT COUNT(*) FILTER (WHERE sa.status_enum = ?) AS matured_closed,
|
||||
COUNT(*) FILTER (WHERE sa.status_enum = ?) AS premature_closed
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
WHERE sa.deposit_type_enum = ? AND sa.closedon_date BETWEEN ? AND ? AND o.hierarchy LIKE ?
|
||||
""";
|
||||
|
||||
/** Missed-installment penalties charged, this month and in all, on RDs in the hierarchy (closed ones included). */
|
||||
private static final String PENALTY_INCOME_SQL = """
|
||||
SELECT COALESCE(SUM(pe.penalty_amount) FILTER (WHERE pe.applied_on_date BETWEEN ? AND ?), 0) AS this_month,
|
||||
COALESCE(SUM(pe.penalty_amount), 0) AS total
|
||||
FROM m_rd_installment_penalty pe
|
||||
JOIN m_savings_account sa ON sa.id = pe.savings_account_id
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
WHERE o.hierarchy LIKE ?
|
||||
""";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String retrieve() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||
|
||||
// "Today" is the business date - the branch's working day, not the server clock.
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate monthStart = today.withDayOfMonth(1);
|
||||
final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
|
||||
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy,
|
||||
monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd);
|
||||
final Map<String, Object> closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE,
|
||||
RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy);
|
||||
final Map<String, Object> penalty = this.jdbcTemplate.queryForMap(PENALTY_INCOME_SQL, monthStart, monthEnd, hierarchy);
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("businessDate", today.toString());
|
||||
result.put("maturedClosedThisMonth", closed.get("matured_closed"));
|
||||
result.put("prematureClosedThisMonth", closed.get("premature_closed"));
|
||||
result.put("penaltyIncomeThisMonth", penalty.get("this_month"));
|
||||
result.put("penaltyIncomeTotal", penalty.get("total"));
|
||||
result.put("accounts", rows);
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Read-only: the loans a recurring deposit currently secures and how much of it each one holds, plus how much more
|
||||
* could be given as loan against it today, so the RD screens can show the lien, its limit, and why the deposit cannot
|
||||
* be closed.
|
||||
*/
|
||||
@Path("/v1/recurringdepositaccounts/{accountId}/liens")
|
||||
@Component
|
||||
@Tag(name = "Recurring Deposit Account", description = "Liens on a recurring deposit pledged as loan security.")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositLienApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final RecurringDepositLienService recurringDepositLienService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String retrieve(@PathParam("accountId") final Long accountId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final List<RecurringDepositLienService.Lien> liens = this.recurringDepositLienService.activeLiens(accountId);
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
final BigDecimal totalLien = liens.stream().map(RecurringDepositLienService.Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
result.put("totalLienAmount", totalLien);
|
||||
final RecurringDepositLienService.LienLimit limit = this.recurringDepositLienService.lienLimit(accountId);
|
||||
if (limit != null) {
|
||||
result.put("lienAllowed", limit.lienAllowed());
|
||||
result.put("lienPercentage", limit.accountPercentage());
|
||||
result.put("productLienPercentage", limit.productPercentage());
|
||||
result.put("effectiveLienPercentage", limit.effectivePercentage());
|
||||
result.put("maxLoanAmount", limit.maxLoanAmount());
|
||||
result.put("availableForLoan", limit.maxLoanAmount().subtract(totalLien).max(BigDecimal.ZERO));
|
||||
}
|
||||
result.put("liens", liens.stream().map(lien -> {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("loanId", lien.loanId());
|
||||
row.put("loanAccountNo", lien.loanAccountNo());
|
||||
row.put("loanStatus", lien.loanStatus());
|
||||
row.put("amount", lien.amount());
|
||||
return row;
|
||||
}).toList());
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -580,7 +580,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -597,7 +597,7 @@ public class SavingsDailyInterestService {
|
||||
SELECT t.transaction_date AS d,
|
||||
SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
ELSE 0 END) AS net
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -613,7 +613,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
|
||||
-36
@@ -180,7 +180,6 @@ public class DepositProductDataValidator {
|
||||
}
|
||||
|
||||
validateRecurringDetailForCreate(element, baseDataValidator);
|
||||
validateLienPercentage(element, baseDataValidator);
|
||||
|
||||
if (!hasRdSlabs) {
|
||||
validateChartsData(element, baseDataValidator);
|
||||
@@ -216,7 +215,6 @@ public class DepositProductDataValidator {
|
||||
}
|
||||
|
||||
validateRecurringDepositUpdate(element, baseDataValidator);
|
||||
validateLienPercentage(element, baseDataValidator);
|
||||
|
||||
// RD products now use the CBS base chart as a read-only template, so
|
||||
// product updates no longer require a charts payload.
|
||||
@@ -227,40 +225,6 @@ public class DepositProductDataValidator {
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
/** Lien Marked's loan limit: a share of the deposit's balance, so above 0 and at most 100. Optional. */
|
||||
private void validateLienPercentage(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
|
||||
if (!this.fromApiJsonHelper.parameterExists("lienPercentage", element)) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal lienPercentage = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("lienPercentage", element);
|
||||
baseDataValidator.reset().parameter("lienPercentage").value(lienPercentage).ignoreIfNull().positiveAmount()
|
||||
.notGreaterThanMax(BigDecimal.valueOf(100));
|
||||
validateMissedInstallmentPenalty(element, baseDataValidator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Missed-installment penalty: grace days are whole days from 0, the type is 1 (flat amount) or 2 (% of the
|
||||
* installment), and the amount is positive - at most 100 when it is a percentage. All optional.
|
||||
*/
|
||||
private void validateMissedInstallmentPenalty(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
|
||||
if (this.fromApiJsonHelper.parameterExists("missedInstallmentGraceDays", element)) {
|
||||
final Integer graceDays = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentGraceDays", element);
|
||||
baseDataValidator.reset().parameter("missedInstallmentGraceDays").value(graceDays).ignoreIfNull().integerZeroOrGreater();
|
||||
}
|
||||
Integer penaltyType = null;
|
||||
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyType", element)) {
|
||||
penaltyType = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentPenaltyType", element);
|
||||
baseDataValidator.reset().parameter("missedInstallmentPenaltyType").value(penaltyType).ignoreIfNull().isOneOfTheseValues(1, 2);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyAmount", element)) {
|
||||
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("missedInstallmentPenaltyAmount", element);
|
||||
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).ignoreIfNull().positiveAmount();
|
||||
if (amount != null && Integer.valueOf(2).equals(penaltyType)) {
|
||||
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).notGreaterThanMax(BigDecimal.valueOf(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDepositDetailForCreate(final JsonElement element, final FromJsonHelper fromApiJsonHelper,
|
||||
final DataValidatorBuilder baseDataValidator, final DepositAccountType accountType) {
|
||||
final String name = fromApiJsonHelper.extractStringNamed(nameParamName, element);
|
||||
|
||||
-3
@@ -67,7 +67,4 @@ public interface DepositAccountDomainService {
|
||||
|
||||
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
|
||||
Map<String, Object> changes);
|
||||
|
||||
/** Names the journal entries of the recurring deposit's interest credits that have no narration yet. */
|
||||
void narrateRdInterestPostings(RecurringDepositAccount account);
|
||||
}
|
||||
|
||||
+15
-194
@@ -72,7 +72,6 @@ import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
@@ -80,7 +79,6 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
|
||||
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -105,7 +103,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -280,13 +277,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
} else {
|
||||
// Paid out in cash or to a GL, so the interest never reaches savings: TDS is tracked and taken here.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -407,13 +400,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
account.updateClosedStatus();
|
||||
account.updateOnAccountClosureStatus(onClosureType);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
// if(!processMaturityInstructionOnly)
|
||||
@@ -448,47 +438,14 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
|
||||
Long savingsTransactionId = null;
|
||||
// Checked before anything is posted or paid out, so a wrong date is reported as such.
|
||||
account.validateMaturityClosureDate(closedDate);
|
||||
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, closedDate, postReversals);
|
||||
// Paid out late: the days between maturity and today earn the product's post-maturity rate, so every closure
|
||||
// branch below - reinvest, transfer or withdraw - carries it.
|
||||
account.postOverdueMaturityInterest(closedDate, postReversals);
|
||||
final BigDecimal transactionAmount = account.getAccountBalance();
|
||||
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||
if (onClosureType.isReinvest()) {
|
||||
BigDecimal reInvestAmount;
|
||||
if (onClosureType.isReinvestPrincipal()) {
|
||||
// getDepositAmount() is the deposit's contracted total (every installment it was meant to receive),
|
||||
// not what actually came in. Capped at the balance, or a deposit with missed installments - or none -
|
||||
// would open its successor with money that was never paid.
|
||||
reInvestAmount = account.getDepositAmount() == null ? account.getAccountBalance()
|
||||
: account.getDepositAmount().min(account.getAccountBalance());
|
||||
// Principal only: the interest part does not go into the new deposit, so it has to be paid
|
||||
// somewhere real first - the savings account named on the request, else the one chosen when
|
||||
// the deposit was opened, else its linked savings account. Previously the full balance was
|
||||
// withdrawn while only the principal was redeposited, so the interest simply vanished.
|
||||
final BigDecimal interestPart = account.getAccountBalance().subtract(reInvestAmount);
|
||||
if (interestPart.signum() > 0) {
|
||||
final Long interestTargetId = resolveRdPayoutSavingsId(account,
|
||||
command.longValueOfParameterNamed(toSavingsAccountIdParamName));
|
||||
if (interestTargetId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.reinvest.principal.needs.savings",
|
||||
"Reinvest Principal Only pays the interest out to a savings account - choose one, or link a Savings account to this deposit.");
|
||||
}
|
||||
final SavingsAccount interestTarget = this.depositAccountAssembler.assembleFrom(interestTargetId,
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final String description = "RD " + account.getAccountNumber() + " interest to savings "
|
||||
+ interestTarget.getAccountNumber() + " (maturity, principal reinvested)";
|
||||
final AccountTransferDTO interestTransfer = new AccountTransferDTO(closedDate, interestPart, PortfolioAccountType.SAVINGS,
|
||||
PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null, null,
|
||||
AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, interestTarget, account,
|
||||
isRegularTransaction, isExceptionForBalanceCheck);
|
||||
narrateTransfer(this.accountTransfersWritePlatformService.transferFunds(interestTransfer), description);
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
}
|
||||
reInvestAmount = account.getDepositAmount();
|
||||
} else {
|
||||
reInvestAmount = account.getAccountBalance();
|
||||
}
|
||||
@@ -504,44 +461,31 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
reinvestedDeposit.generateSchedule(frequencyType, frequency, calendar);
|
||||
reinvestedDeposit.processAccountUponActivation(fmt, postReversals, relaxingDaysConfigForPivotDate);
|
||||
// The opening credit is on the new deposit but its stored balance was never recomputed, so it read 0
|
||||
// until something else touched the account.
|
||||
reinvestedDeposit.updateSavingsAccountSummary(reinvestedDeposit.getTransactions());
|
||||
reinvestedDeposit.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
||||
this.savingsAccountRepository.save(reinvestedDeposit);
|
||||
autoGenerateAccountNumber(reinvestedDeposit);
|
||||
// The money moves deposit-to-deposit and never touches cash: the new deposit's opening credit and the
|
||||
// old deposit's withdrawal below both post against transfers-in-suspense, which nets to nil. Before
|
||||
// this the opening credit posted no journal entry at all and the withdrawal posted as cash paid out,
|
||||
// leaving the RD control GL short and cash overstated by the whole reinvested amount.
|
||||
final boolean isInternalTransferLeg = true;
|
||||
postJournalEntries(reinvestedDeposit, new HashSet<>(), new HashSet<>(), isInternalTransferLeg);
|
||||
|
||||
final boolean applyWithdrawFee = false;
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, applyWithdrawFee, isRegularTransaction, isInternalTransferLeg);
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
// The withdrawal posted journal entries for everything new on the account (the maturity interest
|
||||
// included), so every transaction now on it is accounted for.
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
||||
|
||||
} else if (onClosureType.isTransferToSavings()) {
|
||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "maturity", transferDescription, locale, fmt,
|
||||
isRegularTransaction);
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, transactionAmount,
|
||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
|
||||
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
||||
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -549,28 +493,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.savingsAccountRepository.save(account);
|
||||
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
narrateRdInterestPostings(account);
|
||||
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a recurring deposit's proceeds may be paid when no account was named for this closure: the savings
|
||||
* account chosen when it was opened, else its own linked Savings account (Deposit Account Link).
|
||||
*/
|
||||
private Long resolveRdPayoutSavingsId(final RecurringDepositAccount account, final Long requestedSavingsId) {
|
||||
if (requestedSavingsId != null) {
|
||||
return requestedSavingsId;
|
||||
}
|
||||
if (account.getTransferToSavingsAccountId() != null) {
|
||||
return account.getTransferToSavingsAccountId();
|
||||
}
|
||||
if (account.getLinkedAccountId() != null && "SAVINGS".equalsIgnoreCase(account.getLinkedAccountType())) {
|
||||
return account.getLinkedAccountId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private CalendarInstance getCalendarInstance(final RecurringDepositAccount account, RecurringDepositAccount reinvestedDeposit) {
|
||||
CalendarInstance calendarInstance = null;
|
||||
CalendarInstance parentCalendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(
|
||||
@@ -929,9 +855,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else if (closureType.isTransferToGl()) {
|
||||
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
|
||||
// Taken before the proceeds are read, so the GL receives the payout net of TDS.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"GL");
|
||||
final BigDecimal proceeds = account.getAccountBalance();
|
||||
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
|
||||
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
|
||||
@@ -942,19 +865,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
isRegularTransaction, isInternalTransfer);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
|
||||
command.stringValueOfParameterNamed(transferDescriptionParamName));
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"CASH");
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
|
||||
// accounted for or the postJournalEntries() call below posts it a second time.
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
@@ -1215,13 +1134,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
}
|
||||
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
|
||||
if (alreadyMatured) {
|
||||
account.close(user, command, changes);
|
||||
@@ -1259,9 +1175,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
final Locale locale = command.extractLocale();
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
Long savingsTransactionId = null;
|
||||
// The reason decides the penalty (and, for a deceased holder, lock-in) before any interest is worked out.
|
||||
account.applyPrematureClosureReason(
|
||||
PrematureClosureReasonType.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName)));
|
||||
// post interest
|
||||
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth, postReversals);
|
||||
@@ -1270,51 +1183,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
DepositAccountOnClosureType closureType = DepositAccountOnClosureType.fromInt(closureTypeValue);
|
||||
|
||||
if (closureType.isTransferToSavings()) {
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "premature closure", transferDescription, locale, fmt,
|
||||
isRegularTransaction);
|
||||
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(),
|
||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
|
||||
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
||||
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(closureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
this.savingsAccountRepository.save(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
narrateRdInterestPostings(account);
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the journal entries of every interest credit on the recurring deposit that has none yet - the term's
|
||||
* interest posted at maturity or premature closure, and any post-maturity interest - so the ledger shows whose
|
||||
* interest it is. Called once the closure's own entries are posted, which is the last of them to be written.
|
||||
*/
|
||||
@Override
|
||||
public void narrateRdInterestPostings(final RecurringDepositAccount account) {
|
||||
final String narration = "RD " + account.getAccountNumber() + " interest credited";
|
||||
narrateJournalEntries("""
|
||||
SELECT id FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
|
||||
""", narration, account.getId(), SavingsAccountTransactionType.INTEREST_POSTING.getValue());
|
||||
// An interest credit taken back (a premature closure, or a maturity whose final rate came out lower, credits
|
||||
// the recalculated interest instead): its original entries are already named, so this names only the reversing
|
||||
// entries.
|
||||
narrateJournalEntries("""
|
||||
SELECT id FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = true
|
||||
""", "RD " + account.getAccountNumber() + " interest reversed - recalculated on closure", account.getId(),
|
||||
SavingsAccountTransactionType.INTEREST_POSTING.getValue());
|
||||
}
|
||||
|
||||
private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds,
|
||||
Set<Long> existingReversedTransactionIds) {
|
||||
existingTransactionIds.addAll(account.findExistingTransactionIds());
|
||||
@@ -1331,76 +1222,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a closing recurring deposit into a savings account as two credits - its interest, then the principal -
|
||||
* each described with the deposit's account number, so the savings statement shows which RD the money came from
|
||||
* and how much of it is interest.
|
||||
*
|
||||
* <p>
|
||||
* The interest goes first and as an interest transfer: TDS counts as interest only what a payout carries beyond
|
||||
* the interest already swept out of the deposit, so with the interest swept first the principal transfer after it
|
||||
* is never mistaken for interest, and the interest is tracked for TDS on its own credit.
|
||||
*/
|
||||
private void transferRdPayoutToSavings(final RecurringDepositAccount account, final SavingsAccount toSavingsAccount,
|
||||
final LocalDate closedDate, final String closureLabel, final String clerkNote, final Locale locale,
|
||||
final DateTimeFormatter fmt, final boolean isRegularTransaction) {
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final String note = clerkNote == null || clerkNote.isBlank() ? "" : " - " + clerkNote.trim();
|
||||
final String rd = "RD " + account.getAccountNumber();
|
||||
final BigDecimal interest = account.getInterestInBalance();
|
||||
final BigDecimal principal = account.getAccountBalance().subtract(interest);
|
||||
|
||||
if (interest.signum() > 0) {
|
||||
final String description = rd + " interest to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")" + note;
|
||||
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, interest,
|
||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
|
||||
null, AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
|
||||
isRegularTransaction, isExceptionForBalanceCheck));
|
||||
narrateTransfer(transferId, description);
|
||||
}
|
||||
if (principal.signum() > 0) {
|
||||
final String description = rd + " principal to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")"
|
||||
+ note;
|
||||
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, principal,
|
||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
|
||||
null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
|
||||
isRegularTransaction, isExceptionForBalanceCheck));
|
||||
narrateTransfer(transferId, description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a narration onto the journal entries of both sides of a transfer. The entries are posted while the
|
||||
* transfer is still being made, before its own record - and so its description - exists, so they are named here
|
||||
* once it does.
|
||||
*/
|
||||
private void narrateTransfer(final Long transferDetailsId, final String narration) {
|
||||
if (transferDetailsId == null) {
|
||||
return;
|
||||
}
|
||||
narrateJournalEntries("""
|
||||
SELECT from_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
|
||||
UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
|
||||
""", narration, transferDetailsId, transferDetailsId);
|
||||
}
|
||||
|
||||
/** Sets the narration of the not-yet-narrated journal entries of the savings transactions the query returns. */
|
||||
private void narrateJournalEntries(final String transactionIdsSql, final String narration, final Object... args) {
|
||||
final List<Long> transactionIds = this.jdbcTemplate.queryForList(transactionIdsSql, Long.class, args);
|
||||
for (final Long transactionId : transactionIds) {
|
||||
if (transactionId != null) {
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET description = ? WHERE savings_transaction_id = ? AND (description IS NULL OR description = '')",
|
||||
narration, transactionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How a closure that does not go to savings paid out, for the TDS tracking record. */
|
||||
private static String directPayoutKind(final DepositAccountOnClosureType closureType) {
|
||||
return closureType != null && closureType.isTransferToGl() ? "GL" : "CASH";
|
||||
}
|
||||
|
||||
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
|
||||
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
|
||||
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
|
||||
|
||||
+1293
-1569
File diff suppressed because it is too large
Load Diff
-62
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.jobs.applyrdmissedinstallmentpenalty;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositMissedInstallmentPenaltyService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* Runs the RD missed-installment penalty. The service decides what is due and isolates each installment, so the step
|
||||
* is a thin trigger and the rules live in one place.
|
||||
*/
|
||||
@Configuration
|
||||
public class ApplyRdMissedInstallmentPenaltyJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private RecurringDepositMissedInstallmentPenaltyService recurringDepositMissedInstallmentPenaltyService;
|
||||
|
||||
@Bean
|
||||
protected Step applyRdMissedInstallmentPenaltyStep() {
|
||||
return new StepBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).tasklet((contribution, chunkContext) -> {
|
||||
recurringDepositMissedInstallmentPenaltyService.applyPenalties();
|
||||
return RepeatStatus.FINISHED;
|
||||
}, transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job applyRdMissedInstallmentPenaltyJob() {
|
||||
return new JobBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).start(applyRdMissedInstallmentPenaltyStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.jobs.executerdautodebit;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class ExecuteRdAutoDebitJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private RecurringDepositAutoDebitService recurringDepositAutoDebitService;
|
||||
|
||||
@Bean
|
||||
public ExecuteRdAutoDebitTasklet executeRdAutoDebitTasklet() {
|
||||
return new ExecuteRdAutoDebitTasklet(recurringDepositAutoDebitService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step executeRdAutoDebitStep() {
|
||||
return new StepBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).tasklet(executeRdAutoDebitTasklet(), transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job executeRdAutoDebitJob() {
|
||||
return new JobBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).start(executeRdAutoDebitStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.jobs.executerdautodebit;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
/**
|
||||
* Runs RD Auto Debit. The service decides what is due and isolates each installment, so the tasklet stays a thin
|
||||
* trigger and the rules live in one place.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ExecuteRdAutoDebitTasklet implements Tasklet {
|
||||
|
||||
private final RecurringDepositAutoDebitService service;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||
service.collectDueInstallments();
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
-10
@@ -61,16 +61,6 @@ public class UpdateDepositsAccountMaturityDetailsTasklet implements Tasklet {
|
||||
}
|
||||
}
|
||||
|
||||
// Recurring deposits still running: credit any posting period (monthly, quarterly... per product) that ended
|
||||
// before today. The query above only returns deposits that have reached maturity.
|
||||
for (final Long rdId : depositAccountReadPlatformService.retrieveRunningRecurringDepositIds()) {
|
||||
try {
|
||||
depositAccountWritePlatformService.postRecurringDepositPeriodicInterest(rdId);
|
||||
} catch (final Exception ex) {
|
||||
log.error("Periodic interest posting failed for recurring deposit id {}", rdId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("{}: Records affected by updateMaturityDetailsOfDepositAccounts: {}", ThreadLocalContextUtil.getTenant().getName(),
|
||||
depositAccounts.size());
|
||||
return RepeatStatus.FINISHED;
|
||||
|
||||
-8
@@ -46,7 +46,6 @@ import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class DepositAccountPreMatureCalculationPlatformServiceImpl implements DepositAccountPreMatureCalculationPlatformService {
|
||||
@@ -118,19 +117,12 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
||||
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
|
||||
} else if (depositAccountType.isRecurringDeposit()) {
|
||||
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
|
||||
// Same reason rules the real closure applies, so the preview pays exactly what the closure will.
|
||||
rd.applyPrematureClosureReason(prematureClosureReason);
|
||||
accountData = RecurringDepositAccountData.preClosureDetails(
|
||||
account.getId(), rd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
|
||||
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
|
||||
}
|
||||
|
||||
// A preview, not a closure: working the payout out resets the account's own interest rate to the premature
|
||||
// rate and refreshes its interest-earned summary on the managed entity, and committing that left an RD that
|
||||
// was only previewed showing the penalty rate (4.5% instead of 6.5%) as if it had been broken. Nothing the
|
||||
// calculation touched may be saved.
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return accountData;
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -61,9 +61,6 @@ public interface DepositAccountReadPlatformService {
|
||||
|
||||
Collection<DepositAccountData> retrieveForMaturityUpdate();
|
||||
|
||||
/** Active recurring deposits that have not reached maturity yet - their posting periods are credited nightly. */
|
||||
Collection<Long> retrieveRunningRecurringDepositIds();
|
||||
|
||||
SavingsAccountTransactionData retrieveRecurringAccountDepositTransactionTemplate(Long accountId);
|
||||
|
||||
Collection<AccountTransferDTO> retrieveDataForInterestTransfer();
|
||||
|
||||
-15
@@ -283,18 +283,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
return this.jdbcTemplate.query(sqlBuilder.toString(), DEPOSIT_ACCOUNT_LOOKUP_MAPPER, depositAccountType.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Long> retrieveRunningRecurringDepositIds() {
|
||||
return this.jdbcTemplate.queryForList("""
|
||||
SELECT da.id FROM m_savings_account da
|
||||
JOIN m_deposit_account_term_and_preclosure dat ON dat.savings_account_id = da.id
|
||||
WHERE da.deposit_type_enum = ? AND da.status_enum = ?
|
||||
AND (dat.maturity_date IS NULL OR dat.maturity_date > ?)
|
||||
ORDER BY da.id
|
||||
""", Long.class, DepositAccountType.RECURRING_DEPOSIT.getValue(), SavingsAccountStatusType.ACTIVE.getValue(),
|
||||
DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<DepositAccountData> retrieveForMaturityUpdate() {
|
||||
|
||||
@@ -2069,8 +2057,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
sqlBuilder.append("dptp.max_deposit_term_type_enum as maxDepositTermTypeId, ");
|
||||
sqlBuilder.append("dptp.in_multiples_of_deposit_term as inMultiplesOfDepositTerm, ");
|
||||
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
|
||||
// Lien Marked's loan limit, so the opening screen can default to it and refuse anything above it.
|
||||
sqlBuilder.append("sa.lien_percentage as lienPercentage, ");
|
||||
sqlBuilder.append("sa.account_type as accountType, sa.customer_segment as customerSegment, ");
|
||||
sqlBuilder.append("sa.product_code as productCode, ");
|
||||
sqlBuilder.append("sa.effective_date as effectiveDate, sa.effective_to as effectiveTo, sa.end_date as endDate, ");
|
||||
@@ -2197,7 +2183,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
rs.getBoolean("deceasedSettlement"),
|
||||
rs.getBoolean("allowDebitTxnAfterDueDate")
|
||||
);
|
||||
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
+3
-138
@@ -24,7 +24,6 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.amountPa
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargeIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
@@ -45,7 +44,6 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
@@ -149,8 +147,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
|
||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final RecurringDepositLienService recurringDepositLienService;
|
||||
private final RecurringDepositProductRules recurringDepositProductRules;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -643,12 +639,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
|
||||
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
|
||||
checkClientOrGroupActive(account);
|
||||
if (account instanceof RecurringDepositAccount rdAccount) {
|
||||
// A recurring deposit is credited by its posting periods only - the same credits the nightly job makes.
|
||||
postRecurringDepositPeriodicInterest(rdAccount);
|
||||
} else {
|
||||
postInterest(account);
|
||||
}
|
||||
postInterest(account);
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(savingsId) //
|
||||
.withOfficeId(account.officeId()) //
|
||||
@@ -659,28 +650,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void postRecurringDepositPeriodicInterest(final Long recurringDepositId) {
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(recurringDepositId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
postRecurringDepositPeriodicInterest(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits a recurring deposit's completed posting periods (monthly, quarterly... per its product) with their own
|
||||
* journal entries, named after the deposit.
|
||||
*/
|
||||
private void postRecurringDepositPeriodicInterest(final RecurringDepositAccount rdAccount) {
|
||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
||||
updateExistingTransactionsDetails(rdAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||
rdAccount.postPeriodicInterest(this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
|
||||
this.configurationDomainService.retrieveFinancialYearBeginningMonth(), false);
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(rdAccount);
|
||||
postJournalEntries(rdAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
|
||||
}
|
||||
|
||||
private void postInterest(final SavingsAccount account) {
|
||||
|
||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||
@@ -962,7 +931,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
checkClientOrGroupActive(account);
|
||||
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed");
|
||||
|
||||
this.depositAccountDomainService.handleRDAccountClosure(account, paymentDetail, user, command, changes);
|
||||
|
||||
@@ -1079,43 +1047,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult extendRecurringDepositTenure(final Long savingsId, final JsonCommand command) {
|
||||
this.context.authenticatedUser();
|
||||
final Integer newDepositPeriod = command.integerValueOfParameterNamed(DepositsApiConstants.depositPeriodParamName);
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
checkClientOrGroupActive(account);
|
||||
|
||||
// The added installments follow the deposit's own collection calendar (monthly unless it was opened otherwise).
|
||||
PeriodFrequencyType frequencyType = PeriodFrequencyType.MONTHS;
|
||||
Integer frequency = 1;
|
||||
final CalendarInstance calendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(savingsId,
|
||||
CalendarEntityType.SAVINGS.getValue(), CalendarType.COLLECTION.getValue());
|
||||
if (calendarInstance != null && calendarInstance.getCalendar() != null) {
|
||||
final String recurrence = calendarInstance.getCalendar().getRecurrence();
|
||||
frequencyType = CalendarFrequencyType.from(CalendarUtils.getFrequency(recurrence));
|
||||
frequency = CalendarUtils.getInterval(recurrence);
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = account.extendTenure(newDepositPeriod, frequencyType, frequency,
|
||||
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
|
||||
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
|
||||
this.recurringDepositProductRules.validateMaturityWithinProductEndDate(account);
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(savingsId) //
|
||||
.withOfficeId(account.officeId()) //
|
||||
.withClientId(account.clientId()) //
|
||||
.withGroupId(account.groupId()) //
|
||||
.withSavingsId(savingsId) //
|
||||
.with(changes) //
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
@@ -1128,7 +1059,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
checkClientOrGroupActive(account);
|
||||
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed prematurely");
|
||||
if (account.maturityDate() == null) {
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
||||
@@ -1659,76 +1589,11 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
}
|
||||
}
|
||||
} else if (depositAccountType.isRecurringDeposit()) {
|
||||
final RecurringDepositAccount rdAccount = (RecurringDepositAccount) account;
|
||||
// Every night: credit any posting period (the product's Interest Posting Period) that ended before today.
|
||||
rdAccount.postPeriodicInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
|
||||
rdAccount.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
|
||||
final JsonCommand maturityClosure = rdMaturityInstructionCommand(rdAccount);
|
||||
if (maturityClosure != null) {
|
||||
// Same order as the FD branch above: give the maturity interest its own journal entry first and
|
||||
// mark it posted, so neither the closure's withdrawal nor the final call below posts it again.
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
this.depositAccountDomainService.handleRDAccountClosure(rdAccount, null, this.context.authenticatedUser(),
|
||||
maturityClosure, new HashMap<>());
|
||||
// The closure posted its own entries for everything it created.
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
}
|
||||
((RecurringDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth, postReversals);
|
||||
}
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
if (account instanceof RecurringDepositAccount rdAccount) {
|
||||
// The term's interest credited on the maturity date, named in the ledger as soon as it is posted.
|
||||
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The close command that carries out a recurring deposit's maturity instruction tonight, or null when it must
|
||||
* wait for staff instead. It runs only when the product has Maturity Instructions on, the deposit has just
|
||||
* matured, it was opened with Transfer to Savings or a Reinvest option, nothing holds a lien on it, and - for a
|
||||
* transfer - there is a savings account to pay into (the one chosen at opening, else the linked Savings account).
|
||||
* Anything else stays Matured, earning the post-maturity rate until someone closes it.
|
||||
*/
|
||||
private JsonCommand rdMaturityInstructionCommand(final RecurringDepositAccount rdAccount) {
|
||||
if (!rdAccount.isMatured() || rdAccount.savingsProduct() == null || !rdAccount.savingsProduct().isMaturityInstructions()) {
|
||||
return null;
|
||||
}
|
||||
final DepositAccountOnClosureType instruction = DepositAccountOnClosureType.fromInt(rdAccount.getOnAccountClosureId());
|
||||
if (!(instruction.isTransferToSavings() || instruction.isReinvest()) || instruction.isPartialReinvest()) {
|
||||
return null;
|
||||
}
|
||||
if (rdAccount.getAccountBalance() == null || rdAccount.getAccountBalance().signum() <= 0) {
|
||||
// Nothing was ever paid in: there is nothing to transfer or reinvest, so a person has to look at it.
|
||||
log.info("RD {} matured with no balance; maturity instruction left for staff", rdAccount.getAccountNumber());
|
||||
return null;
|
||||
}
|
||||
if (!this.recurringDepositLienService.activeLiens(rdAccount.getId()).isEmpty()) {
|
||||
log.info("RD {} matured with a lien on it; maturity instruction left for staff", rdAccount.getAccountNumber());
|
||||
return null;
|
||||
}
|
||||
Long payoutSavingsId = rdAccount.getTransferToSavingsAccountId();
|
||||
if (payoutSavingsId == null && rdAccount.getLinkedAccountId() != null
|
||||
&& "SAVINGS".equalsIgnoreCase(rdAccount.getLinkedAccountType())) {
|
||||
payoutSavingsId = rdAccount.getLinkedAccountId();
|
||||
}
|
||||
// Transfer always needs a destination; Reinvest Principal Only needs one for the interest part.
|
||||
if (payoutSavingsId == null && (instruction.isTransferToSavings() || instruction.isReinvestPrincipal())) {
|
||||
log.warn("RD {} matured with instruction {} but no savings account to pay into; left for staff", rdAccount.getAccountNumber(),
|
||||
instruction.getCode());
|
||||
return null;
|
||||
}
|
||||
final JsonObject closure = new JsonObject();
|
||||
closure.addProperty(SavingsApiConstants.closedOnDateParamName, rdAccount.maturityDate().toString());
|
||||
closure.addProperty(SavingsApiConstants.dateFormatParamName, "yyyy-MM-dd");
|
||||
closure.addProperty(SavingsApiConstants.localeParamName, "en");
|
||||
closure.addProperty(DepositsApiConstants.onAccountClosureIdParamName, instruction.getValue());
|
||||
if (payoutSavingsId != null) {
|
||||
closure.addProperty(DepositsApiConstants.toSavingsAccountIdParamName, payoutSavingsId);
|
||||
}
|
||||
closure.addProperty(DepositsApiConstants.transferDescriptionParamName, "Apply maturity instructions");
|
||||
return JsonCommand.fromJsonElement(rdAccount.getId(), closure, new FromJsonHelper());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
-7
@@ -127,7 +127,6 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
private final BusinessEventNotifierService businessEventNotifierService;
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
|
||||
private final RecurringDepositProductRules recurringDepositProductRules;
|
||||
|
||||
/*
|
||||
* Guaranteed to throw an exception no matter what the data integrity issue is.
|
||||
@@ -303,12 +302,9 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
account.generateSchedule(frequencyType, frequency, calendar);
|
||||
final boolean isPreMatureClosure = false;
|
||||
// The simple/compounding flag must be on the account before its maturity is projected.
|
||||
this.recurringDepositProductRules.applyInterestCalculationType(account);
|
||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
account.validateApplicableInterestRate();
|
||||
this.recurringDepositProductRules.validateOnSubmit(account, command);
|
||||
savingAccountRepository.save(account);
|
||||
|
||||
// Save nominees if provided in payload (same transaction)
|
||||
@@ -555,15 +551,12 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
account.generateSchedule(frequencyType, frequency, calendar);
|
||||
final boolean isPreMatureClosure = false;
|
||||
// An application is not yet a contract, so an edit (possibly to a different product) re-reads it.
|
||||
this.recurringDepositProductRules.applyInterestCalculationType(account);
|
||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
account.validateApplicableInterestRate();
|
||||
this.savingAccountRepository.save(account);
|
||||
|
||||
}
|
||||
this.recurringDepositProductRules.validateOnModify(account, command);
|
||||
|
||||
// update calendar details
|
||||
if (!account.isCalendarInherited()) {
|
||||
|
||||
-8
@@ -499,10 +499,6 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
sqlBuilder.append("sp.balance_frequency as balanceFrequency, ");
|
||||
sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, ");
|
||||
sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, sp.msme as msme, ");
|
||||
sqlBuilder.append("sp.lien_percentage as lienPercentage, ");
|
||||
sqlBuilder.append("sp.missed_installment_grace_days as missedInstallmentGraceDays, ");
|
||||
sqlBuilder.append("sp.missed_installment_penalty_type as missedInstallmentPenaltyType, ");
|
||||
sqlBuilder.append("sp.missed_installment_penalty_amount as missedInstallmentPenaltyAmount, ");
|
||||
sqlBuilder.append("sp.auto_debit_facility as autoDebitFacility, sp.loan_facility as loanFacility, ");
|
||||
sqlBuilder.append("sp.premature_withdrawal_option as prematureWithdrawalOption, sp.tenure_flexibility as tenureFlexibility, ");
|
||||
sqlBuilder.append("sp.lien_marked as lienMarked, sp.maturity_instructions as maturityInstructions, ");
|
||||
@@ -627,10 +623,6 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
result.setPrematureWithdrawalOption(prematureWithdrawalOption);
|
||||
result.setTenureFlexibility(tenureFlexibility);
|
||||
result.setLienMarked(lienMarked);
|
||||
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
|
||||
result.setMissedInstallmentGraceDays(JdbcSupport.getInteger(rs, "missedInstallmentGraceDays"));
|
||||
result.setMissedInstallmentPenaltyType(JdbcSupport.getInteger(rs, "missedInstallmentPenaltyType"));
|
||||
result.setMissedInstallmentPenaltyAmount(rs.getBigDecimal("missedInstallmentPenaltyAmount"));
|
||||
result.setMaturityInstructions(maturityInstructions);
|
||||
result.setCreditMaturityProceeds(creditMaturityProceeds);
|
||||
result.setPenaltyGracePeriodMissedInstallments(penaltyGracePeriodMissedInstallments);
|
||||
|
||||
+27
-140
@@ -29,47 +29,19 @@ import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonQuery;
|
||||
import org.apache.fineract.infrastructure.core.domain.LocalDateInterval;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsHelper;
|
||||
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
|
||||
import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositAccountInterestCalculationServiceImpl implements RecurringDepositAccountInterestCalculationService {
|
||||
|
||||
private static final String PRODUCT_ID_PARAM = "productId";
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsHelper savingsHelper;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final RecurringDepositProductRules recurringDepositProductRules;
|
||||
|
||||
/** The interest settings of an RD product, as an account opened under it will be calculated with. */
|
||||
private record ProductInterestTerms(SavingsCompoundingInterestPeriodType compounding, SavingsPostingInterestPeriodType posting,
|
||||
SavingsInterestCalculationType calculationType, SavingsInterestCalculationDaysInYearType daysInYear, boolean simpleInterest,
|
||||
BigDecimal minBalanceForInterest, MonetaryCurrency currency) {}
|
||||
|
||||
@Override
|
||||
public HashMap<String, Object> calculateInterest(JsonQuery query) {
|
||||
@@ -81,120 +53,27 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
|
||||
element);
|
||||
LocalDate submittedOnDate = this.fromApiJsonHelper.extractLocalDateNamed("submittedOnDate", element);
|
||||
if (submittedOnDate == null) {
|
||||
submittedOnDate = DateUtils.getBusinessLocalDate();
|
||||
submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
}
|
||||
final Long productId = this.fromApiJsonHelper.extractLongNamed(PRODUCT_ID_PARAM, element);
|
||||
final ProductInterestTerms terms = productId == null ? null : productInterestTerms(productId);
|
||||
|
||||
final HashMap<String, Object> result = new HashMap<>();
|
||||
BigDecimal maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
|
||||
interestCompoundingPeriodInMonths, submittedOnDate);
|
||||
|
||||
BigDecimal totalPrincipal = depositAmount.multiply(BigDecimal.valueOf(tenureInMonths));
|
||||
BigDecimal maturityAmount;
|
||||
if (terms != null) {
|
||||
// The product's own settings, through the same engine an opened account is calculated with - so the figure
|
||||
// shown before opening is the maturity amount the account will carry.
|
||||
final List<Map<String, Object>> postings = new ArrayList<>();
|
||||
final BigDecimal totalInterest = projectInterest(terms, depositAmount, annualInterestRate, tenureInMonths.intValue(),
|
||||
submittedOnDate, postings);
|
||||
maturityAmount = totalPrincipal.add(totalInterest);
|
||||
result.put("interestCalculation", terms.simpleInterest() ? "Simple" : "Compounding");
|
||||
result.put("compoundingPeriod", terms.compounding().name());
|
||||
result.put("postingPeriod", terms.posting().name());
|
||||
result.put("postingSchedule", postings);
|
||||
result.put("daysInYear", terms.daysInYear().getValue());
|
||||
} else {
|
||||
maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
|
||||
interestCompoundingPeriodInMonths, submittedOnDate);
|
||||
result.put("daysInYear", 365);
|
||||
}
|
||||
BigDecimal totalInterest = maturityAmount.subtract(totalPrincipal);
|
||||
|
||||
String warning = terms != null ? "Calculated with the product's interest settings, assuming every installment is paid on its due date"
|
||||
: "This is an approximate calculated amount - it may vary slightly when the account is created";
|
||||
String warning = "This is an approximate calculated amount - it may vary slightly when the account is created";
|
||||
|
||||
HashMap<String, Object> result = new HashMap<>();
|
||||
result.put("maturityAmount", maturityAmount);
|
||||
result.put("totalPrincipal", totalPrincipal);
|
||||
result.put("totalInterest", totalInterest);
|
||||
result.put("daysInYear", 365); // Standard for this product as per image
|
||||
result.put("warning", warning);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ProductInterestTerms productInterestTerms(final Long productId) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject("""
|
||||
SELECT interest_compounding_period_enum, interest_posting_period_enum, interest_calculation_type_enum,
|
||||
interest_calculation_days_in_year_type_enum, interest_calculate_selector,
|
||||
min_balance_for_interest_calculation, currency_code, currency_digits, currency_multiplesof
|
||||
FROM m_savings_product WHERE id = ? AND deposit_type_enum = 300
|
||||
""", (rs, rowNum) -> {
|
||||
final Integer selector = (Integer) rs.getObject("interest_calculate_selector");
|
||||
final Integer multiplesOf = (Integer) rs.getObject("currency_multiplesof");
|
||||
return new ProductInterestTerms(SavingsCompoundingInterestPeriodType.fromInt(rs.getInt("interest_compounding_period_enum")),
|
||||
SavingsPostingInterestPeriodType.fromInt(rs.getInt("interest_posting_period_enum")),
|
||||
SavingsInterestCalculationType.fromInt(rs.getInt("interest_calculation_type_enum")),
|
||||
SavingsInterestCalculationDaysInYearType.fromInt(rs.getInt("interest_calculation_days_in_year_type_enum")),
|
||||
this.recurringDepositProductRules.isSimpleInterestOption(selector), rs.getBigDecimal("min_balance_for_interest_calculation"),
|
||||
new MonetaryCurrency(rs.getString("currency_code"), rs.getInt("currency_digits"), multiplesOf));
|
||||
}, productId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest on a deposit of {@code installment} every month from {@code startDate} for {@code months} months, worked
|
||||
* out by the account engine itself (posting periods, compounding periods, daily balances, days in year, simple or
|
||||
* compounding) up to the day before maturity - the same calculation that gives an opened account its maturity
|
||||
* amount. Each posting period's credit is added to {@code postings}.
|
||||
*/
|
||||
private BigDecimal projectInterest(final ProductInterestTerms terms, final BigDecimal installment, final BigDecimal annualRate,
|
||||
final int months, final LocalDate startDate, final List<Map<String, Object>> postings) {
|
||||
final LocalDate termEnd = startDate.plusMonths(months).minusDays(1);
|
||||
final MonetaryCurrency currency = terms.currency();
|
||||
|
||||
final List<SavingsAccountTransactionDetailsForPostingPeriod> deposits = new ArrayList<>();
|
||||
for (int k = 0; k < months; k++) {
|
||||
final LocalDate dueDate = startDate.plusMonths(k);
|
||||
final LocalDate balanceEnd = k == months - 1 ? termEnd : startDate.plusMonths(k + 1L).minusDays(1);
|
||||
final BigDecimal runningBalance = installment.multiply(BigDecimal.valueOf(k + 1L));
|
||||
final int days = LocalDateInterval.create(dueDate, balanceEnd).daysInPeriodInclusiveOfEndDate();
|
||||
deposits.add(new SavingsAccountTransactionDetailsForPostingPeriod(null, dueDate, balanceEnd, runningBalance, installment, currency,
|
||||
days, true, false, false, false, false));
|
||||
}
|
||||
|
||||
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||
final boolean postingAtPeriodEnd = this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||
final List<LocalDateInterval> intervals = this.savingsHelper.determineInterestPostingPeriods(startDate, termEnd, terms.posting(),
|
||||
financialYearBeginningMonth, new ArrayList<>());
|
||||
final BigDecimal rateAsFraction = annualRate.divide(BigDecimal.valueOf(100L), MathContext.DECIMAL64);
|
||||
final Money minBalance = Money.of(currency, terms.minBalanceForInterest() == null ? BigDecimal.ZERO : terms.minBalanceForInterest());
|
||||
|
||||
final List<PostingPeriod> periods = new ArrayList<>();
|
||||
Money periodStartingBalance = Money.zero(currency);
|
||||
for (final LocalDateInterval interval : intervals) {
|
||||
final PostingPeriod period = PostingPeriod.createFrom(interval, periodStartingBalance, deposits, currency, terms.compounding(),
|
||||
terms.calculationType(), rateAsFraction, terms.daysInYear().getValue(), termEnd, new ArrayList<>(), false, minBalance,
|
||||
postingAtPeriodEnd, false, financialYearBeginningMonth);
|
||||
periodStartingBalance = period.closingBalance();
|
||||
periods.add(period);
|
||||
}
|
||||
this.savingsHelper.calculateInterestForAllPostingPeriods(currency, periods, null, false, terms.simpleInterest());
|
||||
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final PostingPeriod period : periods) {
|
||||
final BigDecimal interest = period.getInterestEarned().getAmount();
|
||||
total = total.add(interest);
|
||||
final Map<String, Object> posting = new LinkedHashMap<>();
|
||||
// The last period is credited on the maturity date itself.
|
||||
final LocalDate periodEnd = period.getPeriodInterval().endDate();
|
||||
final LocalDate postedOn = periodEnd.isBefore(termEnd) ? periodEnd : termEnd.plusDays(1);
|
||||
posting.put("date", postedOn.toString());
|
||||
posting.put("interest", interest);
|
||||
postings.add(posting);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public BigDecimal calculateInterestInternal(BigDecimal installmentAmount, BigDecimal annualInterestRate, Long tenureInMonths,
|
||||
Long interestCompoundingPeriodInMonths, LocalDate submittedOnDate) {
|
||||
MathContext mc = MoneyHelper.getMathContext();
|
||||
@@ -210,31 +89,39 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
|
||||
for (int m = 1; m <= totalMonths; m++) {
|
||||
// Add monthly installment at the start of the month
|
||||
currentBalance = currentBalance.add(installmentAmount);
|
||||
|
||||
|
||||
// Get actual days in the current month
|
||||
int daysInMonth = date.lengthOfMonth();
|
||||
|
||||
|
||||
// Calculate interest for the month using daily compounding
|
||||
double multiplier = Math.pow(1.0 + dailyRate, daysInMonth);
|
||||
currentBalance = currentBalance.multiply(BigDecimal.valueOf(multiplier), mc);
|
||||
|
||||
|
||||
// Move to next month
|
||||
date = date.plusMonths(1);
|
||||
}
|
||||
maturityAmount = currentBalance;
|
||||
} else {
|
||||
// Periodic Compounding (Monthly, Quarterly, etc.)
|
||||
BigDecimal monthlyRate = annualInterestRate.divide(BigDecimal.valueOf(1200), mc);
|
||||
BigDecimal i = monthlyRate;
|
||||
int n = totalMonths;
|
||||
|
||||
// Standard RD formula for monthly compounding:
|
||||
// M = P * ((1+i)^n - 1) / i * (1+i)
|
||||
// For other periods, we can approximate or use a loop.
|
||||
// Using loop for consistency with various compounding periods.
|
||||
BigDecimal compoundingFrequencyMonths = BigDecimal.valueOf(interestCompoundingPeriodInMonths);
|
||||
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc).multiply(compoundingFrequencyMonths)
|
||||
.divide(BigDecimal.valueOf(12), mc);
|
||||
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc)
|
||||
.multiply(compoundingFrequencyMonths).divide(BigDecimal.valueOf(12), mc);
|
||||
|
||||
for (int m = 1; m <= totalMonths; m++) {
|
||||
BigDecimal monthsRemaining = BigDecimal.valueOf(totalMonths - m + 1);
|
||||
BigDecimal numberOfCompoundings = monthsRemaining.divide(compoundingFrequencyMonths, mc);
|
||||
|
||||
BigDecimal installmentMaturity = installmentAmount
|
||||
.multiply(BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
|
||||
|
||||
|
||||
BigDecimal installmentMaturity = installmentAmount.multiply(
|
||||
BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
|
||||
|
||||
// Add remaining partial compounding period interest (simple interest for the rest)
|
||||
BigDecimal remainingMonths = monthsRemaining.remainder(compoundingFrequencyMonths);
|
||||
if (remainingMonths.compareTo(BigDecimal.ZERO) > 0) {
|
||||
@@ -242,11 +129,11 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
|
||||
.divide(BigDecimal.valueOf(1200), mc);
|
||||
installmentMaturity = installmentMaturity.add(partialInterest);
|
||||
}
|
||||
|
||||
|
||||
maturityAmount = maturityAmount.add(installmentMaturity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return maturityAmount.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.account.PortfolioAccountType;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* RD Auto Debit: on each due date, collects the installment of every recurring deposit opened with Auto Debit from
|
||||
* the Savings account linked to it, as an ordinary account transfer (debit the savings account, credit the deposit),
|
||||
* so it is booked, journalled and shown on both statements exactly like a transfer the customer made themselves.
|
||||
*
|
||||
* <ul>
|
||||
* <li>Only installments due on or before the business date and not yet fully paid are collected - oldest first, one
|
||||
* transfer each, each in its own transaction.</li>
|
||||
* <li>An installment the savings account cannot cover is skipped and stays overdue; nothing is partly collected, and
|
||||
* the rest of that deposit's installments wait for the next run so they are never paid out of order. Every other
|
||||
* deposit is unaffected.</li>
|
||||
* <li>Retrying is the product's choice ("Allow Debit Txn after due date"): with it on, anything still unpaid is
|
||||
* collected on a later run; with it off, an installment is debited only on its due date and a missed one stays
|
||||
* overdue for the customer to pay (and for the missed-installment penalty, if the product has one).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RecurringDepositAutoDebitService {
|
||||
|
||||
/**
|
||||
* Unpaid installments, plus per deposit what has been due up to today and what has actually been paid in. The two
|
||||
* totals matter because a deposit opened without "adjust advance payments" never allocates money paid before a
|
||||
* due date to that installment - the schedule shows it unpaid though the customer already paid at the counter.
|
||||
*/
|
||||
private static final String DUE_INSTALLMENTS_SQL = """
|
||||
SELECT rd.id AS rd_id, rd.linked_account_id AS savings_id, s.duedate AS due_date,
|
||||
s.deposit_amount - COALESCE(s.deposit_amount_completed_derived, 0) AS outstanding,
|
||||
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
|
||||
WHERE s2.savings_account_id = rd.id AND s2.duedate <= ?) AS total_due,
|
||||
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
|
||||
FROM m_savings_account rd
|
||||
JOIN m_savings_product sp ON sp.id = rd.product_id
|
||||
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
|
||||
JOIN m_savings_account sav ON sav.id = rd.linked_account_id
|
||||
WHERE rd.deposit_type_enum = 300
|
||||
AND rd.status_enum = 300
|
||||
AND rd.auto_debit_facility = true
|
||||
AND UPPER(rd.linked_account_type) = 'SAVINGS'
|
||||
AND sav.deposit_type_enum = 100
|
||||
AND sav.status_enum = 300
|
||||
AND COALESCE(s.completed_derived, false) = false
|
||||
AND s.duedate <= ?
|
||||
AND (COALESCE(sp.allow_debit_txn_after_due_date, false) = true OR s.duedate = ?)
|
||||
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
|
||||
ORDER BY rd.id, s.duedate
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AccountTransfersWritePlatformService accountTransfersWritePlatformService;
|
||||
private final TransactionTemplate perInstallmentTransaction;
|
||||
|
||||
public RecurringDepositAutoDebitService(final JdbcTemplate jdbcTemplate,
|
||||
final AccountTransfersWritePlatformService accountTransfersWritePlatformService,
|
||||
final PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.accountTransfersWritePlatformService = accountTransfersWritePlatformService;
|
||||
this.perInstallmentTransaction = new TransactionTemplate(transactionManager);
|
||||
this.perInstallmentTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
/** @return how many installments were collected */
|
||||
public int collectDueInstallments() {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final List<DueInstallment> dueInstallments = this.jdbcTemplate.query(DUE_INSTALLMENTS_SQL,
|
||||
(rs, rowNum) -> new DueInstallment(rs.getLong("rd_id"), rs.getLong("savings_id"), rs.getObject("due_date", LocalDate.class),
|
||||
rs.getBigDecimal("outstanding"), rs.getBigDecimal("total_due").subtract(rs.getBigDecimal("total_paid"))),
|
||||
businessDate, businessDate, businessDate);
|
||||
|
||||
int collected = 0;
|
||||
Long blockedDeposit = null;
|
||||
Long currentDeposit = null;
|
||||
BigDecimal stillShort = BigDecimal.ZERO;
|
||||
for (final DueInstallment installment : dueInstallments) {
|
||||
if (installment.recurringDepositId().equals(blockedDeposit)) {
|
||||
continue;
|
||||
}
|
||||
if (!installment.recurringDepositId().equals(currentDeposit)) {
|
||||
currentDeposit = installment.recurringDepositId();
|
||||
// What the customer still owes up to today - zero when they already paid it at the counter.
|
||||
stillShort = installment.shortfall().max(BigDecimal.ZERO);
|
||||
}
|
||||
final BigDecimal amount = installment.outstanding().min(stillShort);
|
||||
if (amount.signum() <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.perInstallmentTransaction.executeWithoutResult(status -> collect(installment, amount, businessDate));
|
||||
stillShort = stillShort.subtract(amount);
|
||||
collected++;
|
||||
} catch (final RuntimeException e) {
|
||||
// Most often the linked savings account is short of funds. Leave this deposit's remaining
|
||||
// installments for the next run rather than paying a later one ahead of the one that failed.
|
||||
blockedDeposit = installment.recurringDepositId();
|
||||
log.warn("RD Auto Debit could not collect the installment due {} on recurring deposit {} from savings account {}: {}",
|
||||
installment.dueDate(), installment.recurringDepositId(), installment.savingsAccountId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("RD Auto Debit collected {} of {} due installment(s) on {}", collected, dueInstallments.size(), businessDate);
|
||||
return collected;
|
||||
}
|
||||
|
||||
private void collect(final DueInstallment installment, final BigDecimal amount, final LocalDate businessDate) {
|
||||
final SavingsAccount fromSavingsAccount = null;
|
||||
final SavingsAccount toSavingsAccount = null;
|
||||
final boolean isRegularTransaction = true;
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final AccountTransferDTO transfer = new AccountTransferDTO(businessDate, amount, PortfolioAccountType.SAVINGS,
|
||||
PortfolioAccountType.SAVINGS, installment.savingsAccountId(), installment.recurringDepositId(),
|
||||
"RD Auto Debit - installment due " + installment.dueDate(), null, null, null, null, null, null, null,
|
||||
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, fromSavingsAccount,
|
||||
isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(transfer);
|
||||
}
|
||||
|
||||
/** shortfall = everything due on the deposit up to today, less everything actually paid into it. */
|
||||
private record DueInstallment(Long recurringDepositId, Long savingsAccountId, LocalDate dueDate, BigDecimal outstanding,
|
||||
BigDecimal shortfall) {}
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Lien on a recurring deposit pledged as security for a loan.
|
||||
*
|
||||
* <p>
|
||||
* The lien itself is the existing guarantor mechanism: adding the deposit as a guarantor account on a loan records a
|
||||
* funding line, and the guarantor module places the amount on hold when the loan is approved/disbursed, releases it as
|
||||
* the loan is repaid, and releases the rest when the loan closes. What this class adds is what that mechanism never
|
||||
* did for a recurring deposit:
|
||||
* <ul>
|
||||
* <li>only an RD whose product has <b>Lien Marked</b> on may be pledged, and never for more than the product's lien
|
||||
* percentage of its balance (the whole balance when none is set), less what it already secures;</li>
|
||||
* <li>a pledged RD cannot be closed - premature or on maturity - while any loan it secures is still pending or
|
||||
* running. Before this, the hold only surfaced as an "insufficient balance" error deep inside the closure, and a pledge
|
||||
* on a loan not yet approved (no hold placed yet) did not stop the deposit being paid out at all;</li>
|
||||
* <li>which loans hold how much, so the RD screens can show it.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositLienService {
|
||||
|
||||
/**
|
||||
* Pledges still in force: the funding line is active and the loan is submitted (100), approved (200) or active
|
||||
* (300). A loan that is closed, rejected or withdrawn no longer binds the deposit.
|
||||
*/
|
||||
private static final String ACTIVE_LIENS_SQL = """
|
||||
SELECT l.id AS loan_id, l.account_no AS loan_account_no, l.loan_status_id AS loan_status,
|
||||
COALESCE(gfd.amount_remaining_derived, gfd.amount) AS lien_amount
|
||||
FROM m_guarantor_funding_details gfd
|
||||
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
|
||||
JOIN m_loan l ON l.id = paa.loan_account_id
|
||||
WHERE paa.linked_savings_account_id = ?
|
||||
AND gfd.status_enum = 100
|
||||
AND l.loan_status_id IN (100, 200, 300)
|
||||
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
|
||||
ORDER BY l.id
|
||||
""";
|
||||
|
||||
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public record Lien(Long loanId, String loanAccountNo, Integer loanStatus, BigDecimal amount) {}
|
||||
|
||||
/**
|
||||
* The loan limit a deposit carries today: whether its product allows a lien at all, the percentage that applies
|
||||
* (the deposit's own, else its product's, else 100), and what that comes to on the current balance.
|
||||
*/
|
||||
public record LienLimit(boolean lienAllowed, BigDecimal accountPercentage, BigDecimal productPercentage,
|
||||
BigDecimal effectivePercentage, BigDecimal balance, BigDecimal maxLoanAmount) {}
|
||||
|
||||
private static final String LIEN_LIMIT_SQL = """
|
||||
SELECT sp.lien_marked AS lien_marked, sa.lien_percentage AS account_pct, sp.lien_percentage AS product_pct,
|
||||
COALESCE(sa.account_balance_derived, 0) AS balance
|
||||
FROM m_savings_account sa
|
||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||
WHERE sa.id = ?
|
||||
""";
|
||||
|
||||
public LienLimit lienLimit(final Long depositAccountId) {
|
||||
final List<LienLimit> rows = this.jdbcTemplate.query(LIEN_LIMIT_SQL, (rs, rowNum) -> {
|
||||
final boolean lienAllowed = rs.getBoolean("lien_marked");
|
||||
final BigDecimal accountPct = rs.getBigDecimal("account_pct");
|
||||
final BigDecimal productPct = rs.getBigDecimal("product_pct");
|
||||
final BigDecimal balance = rs.getBigDecimal("balance");
|
||||
final BigDecimal effective = accountPct != null ? accountPct : productPct != null ? productPct : ONE_HUNDRED;
|
||||
final BigDecimal maxLoan = lienAllowed ? balance.multiply(effective).divide(ONE_HUNDRED, 2, RoundingMode.DOWN)
|
||||
: BigDecimal.ZERO;
|
||||
return new LienLimit(lienAllowed, accountPct, productPct, effective, balance, maxLoan);
|
||||
}, depositAccountId);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public List<Lien> activeLiens(final Long depositAccountId) {
|
||||
return this.jdbcTemplate.query(ACTIVE_LIENS_SQL, (rs, rowNum) -> new Lien(rs.getLong("loan_id"), rs.getString("loan_account_no"),
|
||||
rs.getInt("loan_status"), rs.getBigDecimal("lien_amount")), depositAccountId);
|
||||
}
|
||||
|
||||
public BigDecimal totalLien(final Long depositAccountId) {
|
||||
return activeLiens(depositAccountId).stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/** Refuses to pay out a recurring deposit that still secures a loan. */
|
||||
public void assertNoActiveLien(final Long depositAccountId, final String action) {
|
||||
final List<Lien> liens = activeLiens(depositAccountId);
|
||||
if (liens.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal total = liens.stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
final String loans = liens.stream().map(lien -> lien.loanAccountNo() + " (" + money(lien.amount()) + ")")
|
||||
.collect(Collectors.joining(", "));
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.marked",
|
||||
"This recurring deposit has a lien of " + money(total) + " for loan(s) " + loans + ". It cannot be " + action
|
||||
+ " until the loan is repaid or the guarantee is released.",
|
||||
depositAccountId, total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for pledging an account as loan security. Only recurring deposits are checked here; other account types
|
||||
* keep the guarantor module's existing behaviour.
|
||||
*
|
||||
* @param excludingLoanId
|
||||
* the loan being edited, whose own earlier pledge must not count against the new amount
|
||||
*/
|
||||
public void validatePledge(final SavingsAccount account, final BigDecimal amount, final Long excludingLoanId) {
|
||||
if (account == null || !account.depositAccountType().isRecurringDeposit()) {
|
||||
return;
|
||||
}
|
||||
if (account.savingsProduct() == null || !account.savingsProduct().isLienMarked()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.not.allowed",
|
||||
"Recurring deposit " + account.getAccountNumber() + " cannot be pledged: its product does not have Lien Marked enabled.",
|
||||
account.getId());
|
||||
}
|
||||
if (!account.isActive()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.account.not.active",
|
||||
"Recurring deposit " + account.getAccountNumber() + " is not active, so it cannot be pledged.", account.getId());
|
||||
}
|
||||
final BigDecimal alreadyPledged = activeLiens(account.getId()).stream()
|
||||
.filter(lien -> excludingLoanId == null || !excludingLoanId.equals(lien.loanId())).map(Lien::amount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
// The lien percentage caps the total lien at that share of the balance as it stands now, when the loan is
|
||||
// taken (90% of 1,00,000 = 90,000 across every loan the deposit secures). The deposit's own percentage, set
|
||||
// when it was opened, wins; else the product's; neither means the whole balance.
|
||||
final BigDecimal lienPercentage = account.getLienPercentage() != null ? account.getLienPercentage()
|
||||
: account.savingsProduct().getLienPercentage();
|
||||
final BigDecimal balance = account.getAccountBalance();
|
||||
final BigDecimal pledgeable = lienPercentage == null ? balance
|
||||
: balance.multiply(lienPercentage).divide(ONE_HUNDRED, 2, RoundingMode.DOWN);
|
||||
final BigDecimal available = pledgeable.subtract(alreadyPledged);
|
||||
if (amount != null && amount.compareTo(available) > 0) {
|
||||
final String limitText = lienPercentage == null ? "balance " + balance.toPlainString()
|
||||
: lienPercentage.stripTrailingZeros().toPlainString() + "% of balance " + balance.toPlainString() + " = "
|
||||
+ pledgeable.toPlainString();
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.exceeds.limit",
|
||||
"Recurring deposit " + account.getAccountNumber() + " can secure at most " + available.max(BigDecimal.ZERO).toPlainString()
|
||||
+ " (" + limitText + ", less " + money(alreadyPledged) + " already pledged); "
|
||||
+ money(amount) + " was requested.",
|
||||
account.getId(), amount, available);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rupees to the paisa for messages - "10000.00", not the stored "10000.000000". */
|
||||
private static String money(final BigDecimal amount) {
|
||||
return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
}
|
||||
-242
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Missed-installment penalty for recurring deposits (product setting "Penalty / Grace Period for Missed
|
||||
* Installments").
|
||||
*
|
||||
* <p>
|
||||
* Every day, each installment still unpaid when its due date plus the product's grace days has passed is charged
|
||||
* the product's penalty once - a flat amount, or a percentage of the installment. The penalty is the system charge
|
||||
* "RD Missed Installment Penalty" added to the deposit and paid out of its balance, so it shows on the statement as
|
||||
* a charge and is booked to the product's Income From Penalties account like any other penalty.
|
||||
*
|
||||
* <ul>
|
||||
* <li>One penalty per installment, ever: m_rd_installment_penalty records which installments have been charged,
|
||||
* and an installment paid late afterwards is not charged again.</li>
|
||||
* <li>Paying an installment within the grace days costs nothing.</li>
|
||||
* <li>When the deposit does not yet hold enough to pay the penalty (the first installment missed, say), the charge
|
||||
* stays on the account as due and every later run tries again.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RecurringDepositMissedInstallmentPenaltyService {
|
||||
|
||||
public static final String PENALTY_CHARGE_NAME = "RD Missed Installment Penalty";
|
||||
public static final int PENALTY_TYPE_FLAT = 1;
|
||||
public static final int PENALTY_TYPE_PERCENT = 2;
|
||||
|
||||
private static final String DATE_FORMAT = "yyyy-MM-dd";
|
||||
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
/** Unpaid installments of active RDs whose product has the penalty on and that have not been penalised yet. */
|
||||
private static final String CANDIDATES_SQL = """
|
||||
SELECT s.id AS installment_id, rd.id AS rd_id, s.duedate AS due_date, s.deposit_amount AS installment_amount,
|
||||
COALESCE(sp.missed_installment_grace_days, 0) AS grace_days,
|
||||
COALESCE(sp.missed_installment_penalty_type, 1) AS penalty_type,
|
||||
sp.missed_installment_penalty_amount AS penalty_amount,
|
||||
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
|
||||
WHERE s2.savings_account_id = rd.id AND s2.duedate <= s.duedate) AS due_through_installment,
|
||||
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
|
||||
FROM m_savings_account rd
|
||||
JOIN m_savings_product sp ON sp.id = rd.product_id
|
||||
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
|
||||
WHERE rd.deposit_type_enum = 300
|
||||
AND rd.status_enum = 300
|
||||
AND sp.penalty_grace_period_missed_installments = true
|
||||
AND COALESCE(sp.missed_installment_penalty_amount, 0) > 0
|
||||
AND COALESCE(s.completed_derived, false) = false
|
||||
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
|
||||
AND s.duedate < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM m_rd_installment_penalty p WHERE p.installment_id = s.id)
|
||||
ORDER BY rd.id, s.duedate
|
||||
""";
|
||||
|
||||
/**
|
||||
* Penalty charges still unpaid on active recurring deposits. Found by the penalty charge definition rather than
|
||||
* through the tracking table, so nothing depends on the charge id being known at the moment it was added.
|
||||
*/
|
||||
private static final String UNPAID_SQL = """
|
||||
SELECT sac.savings_account_id AS rd_id, sac.id AS charge_id, sac.amount_outstanding_derived AS outstanding
|
||||
FROM m_savings_account_charge sac
|
||||
JOIN m_savings_account rd ON rd.id = sac.savings_account_id
|
||||
WHERE sac.charge_id = ?
|
||||
AND rd.deposit_type_enum = 300
|
||||
AND rd.status_enum = 300
|
||||
AND sac.is_active = true
|
||||
AND COALESCE(sac.is_paid_derived, false) = false
|
||||
AND COALESCE(sac.waived, false) = false
|
||||
AND sac.amount_outstanding_derived > 0
|
||||
ORDER BY sac.id
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final TransactionTemplate ownTransaction;
|
||||
|
||||
public RecurringDepositMissedInstallmentPenaltyService(final JdbcTemplate jdbcTemplate,
|
||||
final DepositAccountWritePlatformService depositAccountWritePlatformService, final FromJsonHelper fromJsonHelper,
|
||||
final PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
|
||||
this.fromJsonHelper = fromJsonHelper;
|
||||
this.ownTransaction = new TransactionTemplate(transactionManager);
|
||||
this.ownTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* paidInFull: everything due up to and including this installment has actually been paid into the deposit - e.g.
|
||||
* in advance at the counter, which a deposit opened without "adjust advance payments" never allocates to the
|
||||
* installment itself. Such an installment only looks unpaid and must not be penalised.
|
||||
*/
|
||||
private record Candidate(Long installmentId, Long rdId, LocalDate dueDate, BigDecimal installmentAmount, int graceDays, int penaltyType,
|
||||
BigDecimal penaltyAmount, boolean paidInFull) {}
|
||||
|
||||
private record Unpaid(Long rdId, Long chargeId, BigDecimal outstanding) {}
|
||||
|
||||
/** @return how many installments were newly penalised */
|
||||
public int applyPenalties() {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final Long penaltyChargeId = penaltyChargeId();
|
||||
if (penaltyChargeId == null) {
|
||||
log.warn("Charge '{}' not found - missed-installment penalties cannot be applied", PENALTY_CHARGE_NAME);
|
||||
return 0;
|
||||
}
|
||||
|
||||
final List<Candidate> candidates = this.jdbcTemplate.query(CANDIDATES_SQL,
|
||||
(rs, rowNum) -> new Candidate(rs.getLong("installment_id"), rs.getLong("rd_id"), rs.getObject("due_date", LocalDate.class),
|
||||
rs.getBigDecimal("installment_amount"), rs.getInt("grace_days"), rs.getInt("penalty_type"),
|
||||
rs.getBigDecimal("penalty_amount"),
|
||||
rs.getBigDecimal("total_paid").compareTo(rs.getBigDecimal("due_through_installment")) >= 0),
|
||||
businessDate);
|
||||
|
||||
int penalised = 0;
|
||||
for (final Candidate candidate : candidates) {
|
||||
// Grace: due on the 10th with 5 grace days is penalised from the 16th.
|
||||
if (!candidate.dueDate().plusDays(candidate.graceDays()).isBefore(businessDate) || candidate.paidInFull()) {
|
||||
continue;
|
||||
}
|
||||
final BigDecimal penalty = penaltyFor(candidate);
|
||||
if (penalty.signum() <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.ownTransaction.executeWithoutResult(status -> charge(candidate, penalty, penaltyChargeId, businessDate));
|
||||
penalised++;
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("Could not charge the missed-installment penalty on RD {} (installment due {}): {}", candidate.rdId(),
|
||||
candidate.dueDate(), e.getMessage());
|
||||
}
|
||||
}
|
||||
// Collect everything now due - tonight's penalties and any an earlier run could not cover.
|
||||
collectUnpaid(penaltyChargeId, businessDate);
|
||||
log.info("RD missed-installment penalty: {} installment(s) penalised on {}", penalised, businessDate);
|
||||
return penalised;
|
||||
}
|
||||
|
||||
/** Flat amount, or the percentage of this installment, rounded to the paisa. */
|
||||
static BigDecimal penaltyFor(final Candidate candidate) {
|
||||
if (candidate.penaltyType() == PENALTY_TYPE_PERCENT) {
|
||||
return candidate.installmentAmount().multiply(candidate.penaltyAmount()).divide(ONE_HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return candidate.penaltyAmount().setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** Records the installment as penalised and adds the penalty charge to the deposit, as one unit. */
|
||||
private void charge(final Candidate candidate, final BigDecimal penalty, final Long penaltyChargeId, final LocalDate businessDate) {
|
||||
this.jdbcTemplate.update("""
|
||||
INSERT INTO m_rd_installment_penalty (installment_id, savings_account_id, penalty_amount, applied_on_date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", candidate.installmentId(), candidate.rdId(), penalty, businessDate);
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("chargeId", penaltyChargeId);
|
||||
body.addProperty("amount", penalty);
|
||||
body.addProperty("dueDate", businessDate.toString());
|
||||
body.addProperty("dateFormat", DATE_FORMAT);
|
||||
body.addProperty("locale", "en");
|
||||
final CommandProcessingResult result = this.depositAccountWritePlatformService.addSavingsAccountCharge(command(body, candidate.rdId()),
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
// The charge's id is only known here if the add happened to flush; keep the link when it is, for reference.
|
||||
if (result.getResourceId() != null) {
|
||||
this.jdbcTemplate.update("UPDATE m_rd_installment_penalty SET savings_account_charge_id = ? WHERE installment_id = ?",
|
||||
result.getResourceId(), candidate.installmentId());
|
||||
}
|
||||
}
|
||||
|
||||
private void collectUnpaid(final Long penaltyChargeId, final LocalDate businessDate) {
|
||||
final List<Unpaid> unpaid = this.jdbcTemplate.query(UNPAID_SQL,
|
||||
(rs, rowNum) -> new Unpaid(rs.getLong("rd_id"), rs.getLong("charge_id"), rs.getBigDecimal("outstanding")), penaltyChargeId);
|
||||
for (final Unpaid charge : unpaid) {
|
||||
tryPay(charge.rdId(), charge.chargeId(), charge.outstanding(), businessDate);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pays the penalty from the deposit's balance; if it cannot be covered yet the charge simply stays due. */
|
||||
private void tryPay(final Long rdId, final Long chargeId, final BigDecimal amount, final LocalDate businessDate) {
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("amount", amount);
|
||||
body.addProperty("dueDate", businessDate.toString());
|
||||
body.addProperty("dateFormat", DATE_FORMAT);
|
||||
body.addProperty("locale", "en");
|
||||
try {
|
||||
this.ownTransaction.executeWithoutResult(status -> this.depositAccountWritePlatformService.payCharge(rdId, chargeId,
|
||||
command(body, rdId), DepositAccountType.RECURRING_DEPOSIT));
|
||||
} catch (final RuntimeException e) {
|
||||
log.info("Missed-installment penalty {} on RD {} left due (not collected yet): {}", chargeId, rdId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Long penaltyChargeId() {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject(
|
||||
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false AND is_active = true ORDER BY id LIMIT 1", Long.class,
|
||||
PENALTY_CHARGE_NAME);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A command carrying the deposit's id the way the charge endpoints receive it from the API. */
|
||||
private JsonCommand command(final JsonObject body, final Long rdId) {
|
||||
return new JsonCommand(null, body.toString(), body, this.fromJsonHelper, null, rdId, null, null, null, null, rdId, null, null,
|
||||
null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
-301
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.savings.domain.DepositProductAmountDetails;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProduct;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProduct;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The recurring-deposit product settings that only mean something when an account is opened against them. Each one
|
||||
* used to be stored on the product and then ignored; they are enforced here, on the server, so a client that skips
|
||||
* the screen's own checks cannot open an account the product does not allow.
|
||||
*
|
||||
* <ul>
|
||||
* <li>Effective Date - the first day the product can be used. No account may be opened before it.</li>
|
||||
* <li>Effective To - the last day the product is on sale. No new account may be opened after it; accounts already
|
||||
* open run to maturity as normal.</li>
|
||||
* <li>End Date - the day the product is wound up. No account may be opened that would still be running after it, so
|
||||
* every account opened under the product has matured by then.</li>
|
||||
* <li>Deposit Amount Min / Max - bounds on the installment the customer commits to each period.</li>
|
||||
* <li>Nomination Facility - when on, at least one nominee is required; when off, nominees cannot be recorded.</li>
|
||||
* <li>Auto Debit Facility - only offered when the product allows it, and only with a linked Savings account to debit,
|
||||
* since that is where the installment is collected from.</li>
|
||||
* <li>Interest Calculation Type - copied onto the account as its simple/compounding flag (see
|
||||
* {@link #isSimpleInterestOption}).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositProductRules {
|
||||
|
||||
/** Label of the InterestCalculationTypeOption code value that switches a recurring deposit to simple interest. */
|
||||
public static final String SIMPLE_INTEREST_OPTION_LABEL = "Simple";
|
||||
|
||||
private static final String LINKED_ACCOUNT_TYPE_SAVINGS = "SAVINGS";
|
||||
|
||||
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final CodeValueRepository codeValueRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Fixes the account's simple/compounding flag from its product. Must run before the maturity amount is projected,
|
||||
* which is calculated with it.
|
||||
*/
|
||||
public void applyInterestCalculationType(final RecurringDepositAccount account) {
|
||||
account.setSimpleInterest(isSimpleInterestOption(account.savingsProduct().getInterestCalculateSelector()));
|
||||
}
|
||||
|
||||
/** Rules for a brand-new application. Runs after the maturity date is known, which End Date is checked against. */
|
||||
public void validateOnSubmit(final RecurringDepositAccount account, final JsonCommand command) {
|
||||
final SavingsProduct product = account.savingsProduct();
|
||||
applyProductPreClosurePenalty(account, product);
|
||||
applyLienPercentage(account, product, command, true);
|
||||
validateOpeningWindow(account, product);
|
||||
validateInstallmentAmount(account, product);
|
||||
validateNomination(product, command);
|
||||
validateAutoDebit(account, product);
|
||||
validateIntroducer(account, product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for an application edited before approval. Nominees are only checked when the edit sends them, since an
|
||||
* edit that leaves them out keeps the ones already recorded.
|
||||
*/
|
||||
public void validateOnModify(final RecurringDepositAccount account, final JsonCommand command) {
|
||||
final SavingsProduct product = account.savingsProduct();
|
||||
applyProductPreClosurePenalty(account, product);
|
||||
applyLienPercentage(account, product, command, false);
|
||||
validateOpeningWindow(account, product);
|
||||
validateInstallmentAmount(account, product);
|
||||
if (hasNomineesProperty(command)) {
|
||||
validateNomination(product, command);
|
||||
}
|
||||
validateAutoDebit(account, product);
|
||||
validateIntroducer(account, product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a product's Interest Calculation Type is "Simple". The option is a tenant-editable code value, so it is
|
||||
* matched by label rather than by a hard-coded id; anything else - no selection, "Compounding", an unknown id - is
|
||||
* compounding, which is how every recurring deposit was calculated before the option was honoured.
|
||||
*/
|
||||
public boolean isSimpleInterestOption(final Integer interestCalculateSelector) {
|
||||
if (interestCalculateSelector == null) {
|
||||
return false;
|
||||
}
|
||||
return this.codeValueRepository.findById(interestCalculateSelector.longValue())
|
||||
.map(codeValue -> SIMPLE_INTEREST_OPTION_LABEL.equalsIgnoreCase(StringUtils.trim(codeValue.getLabel()))).orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of this deposit may be given as loan. Only a Lien Marked product allows a lien at all; the account's
|
||||
* percentage may be lowered from the product's but never raised above it. When the application sends none, a new
|
||||
* account takes the product's percentage and an edited one keeps what it had.
|
||||
*/
|
||||
private void applyLienPercentage(final RecurringDepositAccount account, final SavingsProduct product, final JsonCommand command,
|
||||
final boolean isSubmit) {
|
||||
if (!product.isLienMarked()) {
|
||||
account.setLienPercentage(null);
|
||||
return;
|
||||
}
|
||||
final BigDecimal productLimit = product.getLienPercentage() == null ? ONE_HUNDRED : product.getLienPercentage();
|
||||
if (!command.parameterExists("lienPercentage") || command.bigDecimalValueOfParameterNamed("lienPercentage") == null) {
|
||||
if (isSubmit) {
|
||||
account.setLienPercentage(product.getLienPercentage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
final BigDecimal requested = command.bigDecimalValueOfParameterNamed("lienPercentage");
|
||||
if (requested.signum() <= 0 || requested.compareTo(productLimit) > 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.percentage.out.of.range",
|
||||
"Loan against this deposit must be more than 0% and at most " + productLimit.stripTrailingZeros().toPlainString()
|
||||
+ "% (the limit of product '" + product.getName() + "'); " + requested.stripTrailingZeros().toPlainString() + "% was entered.",
|
||||
requested, productLimit);
|
||||
}
|
||||
account.setLienPercentage(requested);
|
||||
}
|
||||
|
||||
private void validateOpeningWindow(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
final LocalDate openedOn = account.getSubmittedOnDate();
|
||||
if (openedOn == null) {
|
||||
return;
|
||||
}
|
||||
final LocalDate effectiveFrom = product.getEffectiveDate();
|
||||
if (effectiveFrom != null && openedOn.isBefore(effectiveFrom)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.not.yet.effective",
|
||||
"Product '" + product.getName() + "' can only be used from " + effectiveFrom + "; this application is dated "
|
||||
+ openedOn + ".",
|
||||
effectiveFrom, openedOn);
|
||||
}
|
||||
final LocalDate effectiveTo = product.getEffectiveTo();
|
||||
if (effectiveTo != null && openedOn.isAfter(effectiveTo)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.closed.for.new.accounts",
|
||||
"Product '" + product.getName() + "' stopped accepting new accounts after " + effectiveTo
|
||||
+ "; this application is dated " + openedOn + ".",
|
||||
effectiveTo, openedOn);
|
||||
}
|
||||
final LocalDate endDate = product.getEndDate();
|
||||
final LocalDate maturityDate = account.maturityDate();
|
||||
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
|
||||
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
|
||||
+ ". Choose a shorter tenure.",
|
||||
maturityDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Apply Penal Interest (less)" is the product's decision, not the account's: whatever the application sent, the
|
||||
* deposit takes the product's penalty - on or off, the same percentage and the same period (Whole Term / Till
|
||||
* Premature Withdrawal). A product without the penalty means no penal interest on premature closure.
|
||||
*/
|
||||
private void applyProductPreClosurePenalty(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (product instanceof RecurringDepositProduct rdProduct && rdProduct.depositProductTermAndPreClosure() != null) {
|
||||
account.applyProductPreClosurePenalty(rdProduct.depositProductTermAndPreClosure().depositPreClosureDetail());
|
||||
}
|
||||
}
|
||||
|
||||
/** A deposit whose tenure is extended must still mature on or before its product's End Date. */
|
||||
public void validateMaturityWithinProductEndDate(final RecurringDepositAccount account) {
|
||||
final SavingsProduct product = account.savingsProduct();
|
||||
final LocalDate endDate = product.getEndDate();
|
||||
final LocalDate maturityDate = account.maturityDate();
|
||||
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
|
||||
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
|
||||
+ ". Choose a shorter tenure.",
|
||||
maturityDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateInstallmentAmount(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (!(product instanceof RecurringDepositProduct rdProduct) || rdProduct.depositProductTermAndPreClosure() == null) {
|
||||
return;
|
||||
}
|
||||
final DepositProductAmountDetails limits = rdProduct.depositProductTermAndPreClosure().depositProductAmountDetails();
|
||||
final BigDecimal installment = account.getRecurringDetail() == null ? null
|
||||
: account.getRecurringDetail().mandatoryRecommendedDepositAmount();
|
||||
if (limits == null || installment == null) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal min = limits.getMinDepositAmount();
|
||||
if (min != null && min.signum() > 0 && installment.compareTo(min) < 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.below.product.minimum",
|
||||
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is below the product minimum of " + min.stripTrailingZeros().toPlainString()
|
||||
+ ".",
|
||||
installment, min);
|
||||
}
|
||||
final BigDecimal max = limits.getMaxDepositAmount();
|
||||
if (max != null && max.signum() > 0 && installment.compareTo(max) > 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.above.product.maximum",
|
||||
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is above the product maximum of " + max.stripTrailingZeros().toPlainString()
|
||||
+ ".",
|
||||
installment, max);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateNomination(final SavingsProduct product, final JsonCommand command) {
|
||||
final boolean hasNominees = hasNominees(command);
|
||||
if (product.isNominationFacility() && !hasNominees) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nominee.required",
|
||||
"Product '" + product.getName() + "' has Nomination Facility enabled - add at least one nominee.");
|
||||
}
|
||||
if (!product.isNominationFacility() && hasNominees) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nomination.not.offered",
|
||||
"Product '" + product.getName() + "' does not offer a Nomination Facility, so nominees cannot be recorded.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Introducer Required" on the product: the deposit must name an introducer - an existing, active customer of the
|
||||
* bank, and not the depositor themselves.
|
||||
*/
|
||||
private void validateIntroducer(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (!product.isIntroducerRequired()) {
|
||||
return;
|
||||
}
|
||||
final Long introducerId = account.getIntroducerClientId();
|
||||
if (introducerId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.required",
|
||||
"Product '" + product.getName() + "' requires an introducer - choose an existing customer as introducer.");
|
||||
}
|
||||
if (introducerId.equals(account.clientId())) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.self",
|
||||
"The depositor cannot be their own introducer.");
|
||||
}
|
||||
final Integer active = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_client WHERE id = ? AND status_enum = 300",
|
||||
Integer.class, introducerId);
|
||||
if (active == null || active == 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.not.active",
|
||||
"The introducer must be an active customer of the bank.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAutoDebit(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (!Boolean.TRUE.equals(account.getAutoDebitFacility())) {
|
||||
return;
|
||||
}
|
||||
if (!product.isAutoDebitFacility()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.not.offered",
|
||||
"Product '" + product.getName() + "' does not offer Auto Debit.");
|
||||
}
|
||||
final Long linkedAccountId = account.getLinkedAccountId();
|
||||
if (linkedAccountId == null || !LINKED_ACCOUNT_TYPE_SAVINGS.equalsIgnoreCase(account.getLinkedAccountType())) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.required",
|
||||
"Auto Debit collects each installment from the linked account, so link an active Savings account to use it.");
|
||||
}
|
||||
final Integer matches = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND client_id = ? AND deposit_type_enum = 100 AND status_enum = 300",
|
||||
Integer.class, linkedAccountId, account.clientId());
|
||||
if (matches == null || matches == 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.not.active",
|
||||
"Linked account " + linkedAccountId + " is not an active Savings account of this client, so Auto Debit cannot use it.",
|
||||
linkedAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasNomineesProperty(final JsonCommand command) {
|
||||
final JsonObject root = command.parsedJson().getAsJsonObject();
|
||||
return root.has("nominees") || root.has("nominee");
|
||||
}
|
||||
|
||||
private static boolean hasNominees(final JsonCommand command) {
|
||||
final JsonObject root = command.parsedJson().getAsJsonObject();
|
||||
final JsonElement nominees = root.get("nominees");
|
||||
if (nominees != null && nominees.isJsonArray()) {
|
||||
return !nominees.getAsJsonArray().isEmpty();
|
||||
}
|
||||
final JsonElement nominee = root.get("nominee");
|
||||
return nominee != null && nominee.isJsonObject();
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -374,10 +374,8 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
// this runs per account inside a batch job, so a null deposit type must not take the whole run down.
|
||||
final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount()
|
||||
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
|
||||
// A simple-interest recurring deposit must post here exactly what its own maturity projection promised, so the
|
||||
// flag is the same one SavingsAccount#calculateInterestUsing honours.
|
||||
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
|
||||
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled, savingsAccountData.isSimpleInterest());
|
||||
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled);
|
||||
|
||||
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
|
||||
|
||||
|
||||
+3
-7
@@ -438,12 +438,10 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
|
||||
// A product on the daily-interest timetable is paid by the pooled posting instead
|
||||
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
|
||||
// FD products are never on that timetable, so they still post here. A recurring deposit (deposit_type_enum
|
||||
// 300) is left out: its posting periods are credited by the nightly deposit maturity job instead
|
||||
// (RecurringDepositAccount#postPeriodicInterest), at the chart rate and on deposits alone. The filters sit inside the paging subquery so a page is never cut short by
|
||||
// accounts that are then dropped.
|
||||
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
|
||||
// subquery so a page is never cut short by accounts that are then dropped.
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? and a.deposit_type_enum <> 300 "
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
|
||||
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
|
||||
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
@@ -472,7 +470,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, ");
|
||||
sqlBuilder.append("sa.deposit_type_enum as depositType, ");
|
||||
sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, ");
|
||||
sqlBuilder.append("sa.is_simple_interest as isSimpleInterest, ");
|
||||
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
|
||||
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
|
||||
sqlBuilder.append("sa.status_enum as statusEnum, ");
|
||||
@@ -842,7 +839,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
// Monthly-payout deposits must not compound: their interest leaves the account every
|
||||
// period, so each period earns on the principal alone.
|
||||
savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount"));
|
||||
savingsAccountData.setSimpleInterest(rs.getBoolean("isSimpleInterest"));
|
||||
}
|
||||
|
||||
if (!transMap.containsValue(transactionId)) {
|
||||
|
||||
+4
-8
@@ -106,8 +106,6 @@ import org.apache.fineract.commands.domain.CommandSourceRepository;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
|
||||
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositProductRules;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountOnHoldTransactionReadPlatformService;
|
||||
@@ -299,8 +297,7 @@ public class SavingsConfiguration {
|
||||
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
|
||||
WorkingDaysRepositoryWrapper workingDaysRepository,
|
||||
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
||||
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate,
|
||||
RecurringDepositLienService recurringDepositLienService, RecurringDepositProductRules recurringDepositProductRules
|
||||
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
|
||||
|
||||
) {
|
||||
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
|
||||
@@ -310,7 +307,7 @@ public class SavingsConfiguration {
|
||||
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
|
||||
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
|
||||
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
|
||||
denominationBreakdownWriter, jdbcTemplate, recurringDepositLienService, recurringDepositProductRules);
|
||||
denominationBreakdownWriter, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -326,14 +323,13 @@ public class SavingsConfiguration {
|
||||
FromJsonHelper fromJsonHelper, CalendarInstanceRepository calendarInstanceRepository,
|
||||
ConfigurationDomainService configurationDomainService, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
|
||||
BusinessEventNotifierService businessEventNotifierService, SavingsAccountAssembler savingsAccountAssembler,
|
||||
ClientNomineeWritePlatformService clientNomineeWritePlatformService,
|
||||
RecurringDepositProductRules recurringDepositProductRules) {
|
||||
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
|
||||
return new DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository,
|
||||
fixedDepositAccountRepository, recurringDepositAccountRepository, depositAccountAssembler, depositAccountDataValidator,
|
||||
accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository, noteRepository, staffRepository,
|
||||
savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler, accountAssociationsRepository,
|
||||
fromJsonHelper, calendarInstanceRepository, configurationDomainService, accountNumberFormatRepository,
|
||||
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService, recurringDepositProductRules);
|
||||
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+2
-4
@@ -281,9 +281,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, "
|
||||
+ "m.dd_limit_amount "
|
||||
+ "from m_sponsor_dd_gl_mapping m "
|
||||
// Left join: DD Payable moved to the sponsor bank (0570) and became nullable here (0573), so
|
||||
// an inner join dropped the whole row and the settings screen reopened blank.
|
||||
+ "left join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
+ "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
+ "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id "
|
||||
+ "left join acc_gl_account cash on cash.id = m.cash_gl_account_id "
|
||||
+ "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id "
|
||||
@@ -312,7 +310,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
final Long id = rs.getLong("id");
|
||||
final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null;
|
||||
final String sponsorBankName = rs.getString("sponsor_bank_name");
|
||||
final Long ddPayableGlAccountId = rs.getObject("dd_payable_gl_account_id") != null ? rs.getLong("dd_payable_gl_account_id") : null;
|
||||
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
|
||||
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
|
||||
final String ddPayableGlCode = rs.getString("dd_payable_gl_code");
|
||||
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
|
||||
|
||||
+2
-2
@@ -52,10 +52,10 @@ public class TdsInterestApiResource {
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Deposit interest per customer for a financial year",
|
||||
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear, @QueryParam("clientId") final Long clientId) {
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
|
||||
this.context.authenticatedUser();
|
||||
final List<TdsDeducteeData> deductees = this.readPlatformService
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId);
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
|
||||
return this.toApiJsonSerializer.serialize(deductees);
|
||||
}
|
||||
|
||||
|
||||
-490
@@ -1,490 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.service.TdsSql;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The two TDS records the bank keeps by hand: the declarations customers give at the counter, and the
|
||||
* challans Head Office remits the deducted tax with.
|
||||
*
|
||||
* <p>
|
||||
* Both feed the deduction and the reports directly. A declaration stops the deduction from the day it
|
||||
* is accepted; a challan is what the remittance report reconciles the deducted tax against.
|
||||
*/
|
||||
@Path("/v1/tds")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class TdsRecordsApiResource {
|
||||
|
||||
private static final String DECLARATION = "TDSDECLARATION";
|
||||
private static final String CHALLAN = "TDSCHALLAN";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ─────────────────────────── declarations ───────────────────────────
|
||||
|
||||
/** A customer's declarations, every year and revoked ones included, newest year first. */
|
||||
@GET
|
||||
@Path("declarations")
|
||||
public String declarations(@QueryParam("clientId") final Long clientId, @QueryParam("fy") final String fy) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (clientId == null && fy == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declarations.filter.required", "Give a clientId or a fy.");
|
||||
}
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final StringBuilder where = new StringBuilder(" WHERE o.hierarchy LIKE ?");
|
||||
params.add(user.getOffice().getHierarchy() + "%");
|
||||
if (clientId != null) {
|
||||
where.append(" AND d.client_id = ?");
|
||||
params.add(clientId);
|
||||
}
|
||||
if (fy != null) {
|
||||
where.append(" AND d.fy_label = ?");
|
||||
params.add(fy);
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT d.id, d.client_id, c.display_name AS client_name, d.fy_label, d.form_type, d.uin, d.accepted_on,
|
||||
d.estimated_income, d.is_revoked, d.revoked_on, d.revoke_reason
|
||||
FROM m_tds_declaration d
|
||||
JOIN m_client c ON c.id = d.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
""" + where + " ORDER BY d.fy_label DESC, d.accepted_on DESC", params.toArray());
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("clientId", r.get("client_id"));
|
||||
o.put("clientName", r.get("client_name"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("formType", r.get("form_type"));
|
||||
o.put("uin", r.get("uin"));
|
||||
o.put("acceptedOn", str(r.get("accepted_on")));
|
||||
o.put("estimatedIncome", r.get("estimated_income"));
|
||||
o.put("revoked", r.get("is_revoked"));
|
||||
o.put("revokedOn", str(r.get("revoked_on")));
|
||||
o.put("revokeReason", r.get("revoke_reason"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a declaration. One live declaration per customer per year; the form must be the one the
|
||||
* year's configuration names (Form 121 from 2026-27, 15G/15H before), the date must fall in the year
|
||||
* and not in the future, and the customer must have a PAN - a declaration without one is invalid.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createDeclaration(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final Long clientId = requiredLong(root, "clientId");
|
||||
assertInScope(user, clientId);
|
||||
final String fy = requiredText(root, "fy");
|
||||
final String formType = requiredText(root, "formType").toUpperCase();
|
||||
final LocalDate acceptedOn = requiredDate(root, "acceptedOn");
|
||||
final String uin = optionalText(root, "uin");
|
||||
final BigDecimal estimatedIncome = root.has("estimatedIncome") && !root.get("estimatedIncome").isJsonNull()
|
||||
&& !root.get("estimatedIncome").getAsString().isBlank() ? root.get("estimatedIncome").getAsBigDecimal() : null;
|
||||
|
||||
final List<Map<String, Object>> cfg = jdbcTemplate.queryForList("SELECT declaration_form FROM m_tds_fy_config WHERE fy_label = ?",
|
||||
fy);
|
||||
if (cfg.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.fy.not.configured",
|
||||
"Financial year " + fy + " is not set up under Organization › TDS Configuration.");
|
||||
}
|
||||
final String yearForm = (String) cfg.get(0).get("declaration_form");
|
||||
final boolean merged = "121".equals(yearForm);
|
||||
if (merged ? !"121".equals(formType) : !("15G".equals(formType) || "15H".equals(formType))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.year",
|
||||
merged ? "From " + fy + " the declaration is Form 121." : "For " + fy + " the declaration is Form 15G or 15H.");
|
||||
}
|
||||
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate fyStart = LocalDate.of(startYear, 4, 1);
|
||||
final LocalDate fyEnd = LocalDate.of(startYear + 1, 3, 31);
|
||||
if (acceptedOn.isBefore(fyStart) || acceptedOn.isAfter(fyEnd)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.outside.year",
|
||||
"The acceptance date must fall inside " + fy + ".");
|
||||
}
|
||||
if (acceptedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.in.future",
|
||||
"A declaration cannot be accepted on a future date.");
|
||||
}
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", clientId);
|
||||
if (!TdsSql.isWellFormedPan((String) person.get("pan"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.pan.required",
|
||||
"A declaration is valid only with the customer's PAN. Record a PAN on the KYC first.");
|
||||
}
|
||||
if (!merged) {
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), fyEnd);
|
||||
if (senior != "15H".equals(formType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.age",
|
||||
senior ? "This customer is 60 or over in " + fy + ", so the form is 15H." : "This customer is under 60 in " + fy
|
||||
+ ", so the form is 15G.");
|
||||
}
|
||||
}
|
||||
|
||||
final Integer live = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_declaration WHERE client_id = ? AND fy_label = ? AND is_revoked = false", Integer.class,
|
||||
clientId, fy);
|
||||
if (live != null && live > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.duplicate",
|
||||
"This customer already has a declaration on record for " + fy + ". Revoke it first to replace it.");
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_declaration (client_id, fy_label, form_type, uin, accepted_on, estimated_income,
|
||||
is_revoked, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", clientId, fy, formType, uin, acceptedOn, estimatedIncome, user.getId());
|
||||
return declarations(clientId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraws a declaration from a date. Interest credited from then on is deducted again; what was
|
||||
* paid gross while it stood stays paid gross, because it was lawful at the time.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations/{id}/revoke")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String revokeDeclaration(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
final List<Long> owner = jdbcTemplate.queryForList("SELECT client_id FROM m_tds_declaration WHERE id = ? AND is_revoked = false",
|
||||
Long.class, id);
|
||||
if (owner.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.not.found", "No live declaration " + id + ".");
|
||||
}
|
||||
assertInScope(user, owner.get(0));
|
||||
final String reason = requiredText(root, "reason");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_tds_declaration
|
||||
SET is_revoked = true, revoked_on = ?, revoke_reason = ?, revoked_by = ?
|
||||
WHERE id = ?
|
||||
""", DateUtils.getBusinessLocalDate(), reason, user.getId(), id);
|
||||
return declarations(owner.get(0), null);
|
||||
}
|
||||
|
||||
// ─────────────────────────── challans ───────────────────────────
|
||||
|
||||
/** Every challan entered for the year, reversed ones included so the trail stays visible. */
|
||||
@GET
|
||||
@Path("challans")
|
||||
public String challans(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT ch.id, ch.fy_label, ch.deduction_month, ch.amount, ch.challan_no, ch.bsr_code, ch.deposited_on,
|
||||
ch.paid_from_gl_account_id, g.gl_code, g.name AS gl_name, ch.journal_transaction_id, ch.remarks,
|
||||
ch.is_reversed
|
||||
FROM m_tds_challan ch
|
||||
JOIN acc_gl_account g ON g.id = ch.paid_from_gl_account_id
|
||||
WHERE ch.fy_label = ?
|
||||
ORDER BY ch.deduction_month, ch.deposited_on, ch.id
|
||||
""", fy);
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("month", r.get("deduction_month"));
|
||||
o.put("amount", r.get("amount"));
|
||||
o.put("challanNo", r.get("challan_no"));
|
||||
o.put("bsrCode", r.get("bsr_code"));
|
||||
o.put("depositedOn", str(r.get("deposited_on")));
|
||||
o.put("paidFromGlAccountId", r.get("paid_from_gl_account_id"));
|
||||
o.put("paidFrom", r.get("gl_code") + " · " + r.get("gl_name"));
|
||||
o.put("journalTransactionId", r.get("journal_transaction_id"));
|
||||
o.put("remarks", r.get("remarks"));
|
||||
o.put("reversed", r.get("is_reversed"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax actually deducted, month by month, for the year: the TDS debits on savings accounts for FD/RD
|
||||
* interest received there. This is what the challans must add up to.
|
||||
*/
|
||||
@GET
|
||||
@Path("deducted")
|
||||
public String deducted(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
requireFy(fy);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT TO_CHAR(d.deducted_on, 'YYYY-MM') AS month, SUM(d.amount) AS amount, COUNT(*) AS postings
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.fy_label = ?
|
||||
GROUP BY 1 ORDER BY 1
|
||||
""", fy);
|
||||
// The business date, not the wall clock: whether a challan is overdue is judged on the day the
|
||||
// books are on.
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("asAt", DateUtils.getBusinessLocalDate().toString());
|
||||
out.put("months", rows);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/** Accounts the tax can be paid out of: active, postable asset accounts (bank, cash). */
|
||||
@GET
|
||||
@Path("challans/template")
|
||||
public String challanTemplate() {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT id, gl_code AS "glCode", name FROM acc_gl_account
|
||||
WHERE disabled = false AND account_usage = 1 AND classification_enum = 1
|
||||
ORDER BY gl_code
|
||||
""");
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("paidFromOptions", accounts);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a remittance and posts it: Dr TDS payable / Cr the account paid from, at Head Office.
|
||||
* Only Head Office remits - the bank files one return under one TAN.
|
||||
*/
|
||||
@POST
|
||||
@Path("challans")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createChallan(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final String fy = requireFy(requiredText(root, "fy"));
|
||||
final String month = requiredText(root, "month");
|
||||
if (!month.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.invalid", "Month must look like 2026-06.");
|
||||
}
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate monthStart = LocalDate.parse(month + "-01");
|
||||
if (monthStart.isBefore(LocalDate.of(startYear, 4, 1)) || monthStart.isAfter(LocalDate.of(startYear + 1, 3, 1))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.outside.year", "The month must fall inside " + fy + ".");
|
||||
}
|
||||
final BigDecimal amount = root.get("amount").getAsBigDecimal();
|
||||
if (amount.signum() <= 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.amount.invalid", "Amount must be more than zero.");
|
||||
}
|
||||
final String challanNo = requiredText(root, "challanNo");
|
||||
final String bsrCode = requiredText(root, "bsrCode");
|
||||
if (!bsrCode.matches("\\d{7}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.bsr.invalid", "BSR code is 7 digits.");
|
||||
}
|
||||
final LocalDate depositedOn = requiredDate(root, "depositedOn");
|
||||
if (depositedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.date.in.future", "Deposit date cannot be in the future.");
|
||||
}
|
||||
final Long paidFrom = requiredLong(root, "paidFromGlAccountId");
|
||||
final String remarks = optionalText(root, "remarks");
|
||||
|
||||
final Integer dup = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_challan WHERE challan_no = ? AND bsr_code = ? AND deposited_on = ? AND is_reversed = false",
|
||||
Integer.class, challanNo, bsrCode, depositedOn);
|
||||
if (dup != null && dup > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.duplicate", "This challan is already on record.");
|
||||
}
|
||||
|
||||
final Long tdsPayable = tdsPayableGl();
|
||||
final long officeId = user.getOffice().getId();
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final String txnId = "TDSCH-" + UUID.randomUUID().toString().substring(0, 18);
|
||||
final String narration = "TDS remitted for " + month + " · challan " + challanNo + " · BSR " + bsrCode;
|
||||
journalEntry(officeId, currency, tdsPayable, depositedOn, amount, true, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, paidFrom, depositedOn, amount, false, txnId, narration, user.getId());
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_challan (fy_label, deduction_month, amount, challan_no, bsr_code, deposited_on,
|
||||
paid_from_gl_account_id, office_id, journal_transaction_id, remarks,
|
||||
is_reversed, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", fy, month, amount, challanNo, bsrCode, depositedOn, paidFrom, officeId, txnId, remarks, user.getId());
|
||||
return challans(fy);
|
||||
}
|
||||
|
||||
/** Undoes a challan entered in error: the entry is reversed with an opposite one, the row kept. */
|
||||
@POST
|
||||
@Path("challans/{id}/reverse")
|
||||
@Transactional
|
||||
public String reverseChallan(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT fy_label, amount, paid_from_gl_account_id, office_id, journal_transaction_id, challan_no FROM m_tds_challan"
|
||||
+ " WHERE id = ? AND is_reversed = false",
|
||||
id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.not.found", "No live challan " + id + ".");
|
||||
}
|
||||
final Map<String, Object> ch = rows.get(0);
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final String txnId = "TDSCHR-" + UUID.randomUUID().toString().substring(0, 17);
|
||||
final String narration = "Reversal of TDS challan " + ch.get("challan_no");
|
||||
final long officeId = ((Number) ch.get("office_id")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) ch.get("amount");
|
||||
journalEntry(officeId, currency, tdsPayableGl(), today, amount, false, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, ((Number) ch.get("paid_from_gl_account_id")).longValue(), today, amount, true, txnId, narration,
|
||||
user.getId());
|
||||
jdbcTemplate.update("UPDATE m_tds_challan SET is_reversed = true WHERE id = ?", id);
|
||||
return challans((String) ch.get("fy_label"));
|
||||
}
|
||||
|
||||
// ─────────────────────────── helpers ───────────────────────────
|
||||
|
||||
/** Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. */
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final boolean debit, final String reference, final String narration, final Long userId) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
||||
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
|
||||
reversed, manual_entry, office_running_balance, organization_running_balance,
|
||||
created_by, last_modified_by, created_date, lastmodified_date, created_on_utc,
|
||||
last_modified_on_utc, submitted_on_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, false, false, true, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, userId, userId, date);
|
||||
}
|
||||
|
||||
private Long tdsPayableGl() {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.payable.gl.not.configured",
|
||||
"No TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
return gl.get(0);
|
||||
}
|
||||
|
||||
private void assertHeadOffice(final AppUser user) {
|
||||
if (user.getOffice().getParent() != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.head.office.only",
|
||||
"TDS is remitted by Head Office. A branch login cannot record a challan.");
|
||||
}
|
||||
}
|
||||
|
||||
/** A user may act only on customers of their own office or the offices below it. */
|
||||
private void assertInScope(final AppUser user, final Long clientId) {
|
||||
final Integer n = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_client c JOIN m_office o ON o.id = c.office_id
|
||||
WHERE c.id = ? AND o.hierarchy LIKE ?
|
||||
""", Integer.class, clientId, user.getOffice().getHierarchy() + "%");
|
||||
if (n == null || n == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.client.out.of.scope",
|
||||
"Customer " + clientId + " does not belong to your office.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireFy(final String fy) {
|
||||
if (fy == null || !fy.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.fy.invalid", "Financial year must look like 2026-27.");
|
||||
}
|
||||
return fy;
|
||||
}
|
||||
|
||||
private static String str(final Object v) {
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static String optionalText(final JsonObject root, final String name) {
|
||||
if (!root.has(name) || root.get(name).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String v = root.get(name).getAsString().trim();
|
||||
return v.isEmpty() ? null : v;
|
||||
}
|
||||
|
||||
private static String requiredText(final JsonObject root, final String name) {
|
||||
final String v = optionalText(root, name);
|
||||
if (v == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".required", name + " is required.");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static Long requiredLong(final JsonObject root, final String name) {
|
||||
try {
|
||||
return Long.valueOf(requiredText(root, name));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a number.");
|
||||
}
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd. */
|
||||
private static LocalDate requiredDate(final JsonObject root, final String name) {
|
||||
try {
|
||||
return LocalDate.parse(requiredText(root, name));
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a date like 2026-09-15.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-18
@@ -19,13 +19,12 @@
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A customer whose deposit interest the branch must consider for deduction, together with the
|
||||
* accounts and the interest actually credited to them.
|
||||
* A customer whose deposit interest the branch must consider for deduction under section 194A,
|
||||
* together with the accounts and the interest actually credited to them.
|
||||
*
|
||||
* <p>
|
||||
* The section bites on time deposits, so savings accounts are carried here as well but marked by
|
||||
@@ -33,31 +32,23 @@ import java.util.List;
|
||||
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
|
||||
* customer holds a savings balance at all, which is the first thing an officer checks when a
|
||||
* customer disputes a deduction.
|
||||
*
|
||||
* <p>
|
||||
* The customer here is the <em>deductee</em>: a minor's deposit is listed under the guardian and a
|
||||
* joint deposit under its primary holder, because that is whose PAN the tax is deducted against.
|
||||
*/
|
||||
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, List<TdsDeclarationData> declarations, List<TdsAccountData> accounts) implements Serializable {
|
||||
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
|
||||
|
||||
/**
|
||||
* One deposit account, the interest posted to it and the TDS actually deducted from it.
|
||||
* One deposit account and the interest posted to it.
|
||||
*
|
||||
* @param depositTypeCode
|
||||
* Fineract's own code - {@code depositAccountType.fixedDeposit},
|
||||
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
|
||||
* the charge applies at all, so it is sent as the platform states it rather than as
|
||||
* a flag this report worked out for itself.
|
||||
* @param clientId
|
||||
* the deductee, which is the account holder unless the account is a minor's or joint
|
||||
* @param holderClientId
|
||||
* the account's own holder, shown when it differs from the deductee
|
||||
*/
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId,
|
||||
String holderName, String accountStatus, LocalDate openedOn, List<TdsInterestCreditData> credits,
|
||||
List<TdsInterestCreditData> deductions) implements Serializable {}
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
|
||||
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
|
||||
|
||||
/** One interest posting, or one TDS deduction: the date and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, BigDecimal amount, String accountNo) implements Serializable {}
|
||||
/** One interest posting: the date it was credited and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
|
||||
implements Serializable {}
|
||||
}
|
||||
|
||||
-389
@@ -1,389 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponent;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* TDS on FD/RD interest, tracked and deducted where the interest is received.
|
||||
*
|
||||
* <p>
|
||||
* Normally that is the customer's savings account: every time money moves from a fixed or recurring
|
||||
* deposit into savings, the interest in it is recorded against the deductee (see
|
||||
* {@link TdsSql#deducteeOf}) and any TDS due is debited from that savings account. The periodic
|
||||
* interest sweep is all interest; a maturity or closure payout is principal and interest together, so
|
||||
* only the interest not already received is counted. The FD and RD are not debited.
|
||||
*
|
||||
* <p>
|
||||
* When a maturity or closure pays out in cash, by cheque or to a GL account instead, the interest never
|
||||
* reaches savings, so it is tracked at the payout and the TDS comes out of the payout itself. A full
|
||||
* reinvestment pays nothing out and is not taxed here.
|
||||
*
|
||||
* <p>
|
||||
* The rules, all for one financial year and one deductee:
|
||||
* <ol>
|
||||
* <li>The FD/RD interest received, across every branch, is added up. The savings account's own interest
|
||||
* is not - it is outside the charge.</li>
|
||||
* <li>A declaration shields the interest received from the day it is accepted. Interest received
|
||||
* before it stays liable.</li>
|
||||
* <li>Nothing is deducted while the total stays within the threshold (senior or general).</li>
|
||||
* <li>Once over it, the whole year's interest is liable - not merely the excess - at the PAN rate, or
|
||||
* the higher rate without a valid PAN.</li>
|
||||
* <li>What is deducted now is that liability less everything already deducted this year, so the credit
|
||||
* that crosses the threshold catches up the earlier ones, and nothing is ever deducted twice.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* The tax is posted as a withhold-tax transaction through a tax component whose credit account is the
|
||||
* TDS payable account set on Organization › TDS Configuration, so the passbook shows it and the
|
||||
* account's own accounting writes Dr deposit liability / Cr TDS payable.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SavingsInterestTdsService {
|
||||
|
||||
/** The tax component every TDS deduction is booked through. Kept in step with the GL setting. */
|
||||
public static final String TDS_COMPONENT_NAME = "TDS on deposit interest";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final TaxComponentRepository taxComponentRepository;
|
||||
private final EntityManager entityManager;
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepository;
|
||||
private final SavingsAccountTransactionRepository savingsAccountTransactionRepository;
|
||||
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
|
||||
|
||||
/**
|
||||
* Called once a savings-to-savings transfer has been saved.
|
||||
*
|
||||
* @param deposit
|
||||
* the credit on the receiving savings account
|
||||
*/
|
||||
public void onTransfer(final SavingsAccount from, final SavingsAccount to, final SavingsAccountTransaction deposit,
|
||||
final AccountTransferType transferType) {
|
||||
if (from == null || to == null || deposit == null || deposit.getId() == null || !isTimeDeposit(from)) {
|
||||
return;
|
||||
}
|
||||
final DepositAccountType toType = to.depositAccountType();
|
||||
if (toType == null || !toType.isSavingsDeposit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final boolean sweep = transferType != null && transferType.isInterestTransfer();
|
||||
final BigDecimal interest = sweep ? deposit.getAmount()
|
||||
: interestInPayout(from.getId(), postedFromDatabase(from.getId()), deposit.getAmount());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDate date = deposit.getTransactionDate();
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(from, year);
|
||||
if (deductee == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Long receiptId = insertReceipt(deductee, to.getId(), deposit.getId(), from.getId(), sweep ? "INTEREST" : "PAYOUT", year,
|
||||
date, interest);
|
||||
|
||||
final Due due = dueFor(deductee, year, date, BigDecimal.ZERO);
|
||||
if (due == null) {
|
||||
return;
|
||||
}
|
||||
final Set<Long> existingIds = new HashSet<>(to.findExistingTransactionIds());
|
||||
final Set<Long> existingReversedIds = new HashSet<>(to.findExistingReversedTransactionIds());
|
||||
final SavingsAccountTransaction tds = to.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
// Saved on its own first, as Fineract does for every new transaction: saving only the account
|
||||
// merges it, and the id lands on the merged copy - this instance stays null, which broke both
|
||||
// the deduction row and the journal entry's transaction reference.
|
||||
this.savingsAccountTransactionRepository.saveAndFlush(tds);
|
||||
this.savingsAccountRepository.saveAndFlush(to);
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(
|
||||
to.deriveAccountingBridgeData(to.getCurrency().getCode(), existingIds, existingReversedIds, false, false));
|
||||
recordDeduction(deductee, to.getId(), tds.getId(), from.getId(), receiptId, year, date, due);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest paid straight out of an FD/RD at maturity or closure - in cash, by cheque, or to a GL
|
||||
* account - never reaches a savings account, so it is tracked here instead, and any TDS it makes due
|
||||
* is taken out of the payout itself.
|
||||
*
|
||||
* <p>
|
||||
* Call just before the closing withdrawal is worked out: the deduction is added to the deposit, so a
|
||||
* withdrawal of the account balance pays out what is left. The deposit is not saved here - the
|
||||
* closing withdrawal saves it and posts the deduction's accounting along with its own. Then call
|
||||
* {@link #recordDirectPayout}.
|
||||
*
|
||||
* @param kind
|
||||
* CASH or GL, for the tracking record
|
||||
* @return what to record once the withdrawal exists, or null when the payout carried no interest
|
||||
*/
|
||||
public DirectPayout deductOnDirectPayout(final SavingsAccount deposit, final LocalDate date, final String kind) {
|
||||
if (deposit == null || deposit.getId() == null || date == null || !isTimeDeposit(deposit)) {
|
||||
return null;
|
||||
}
|
||||
// From memory: the maturity or pre-closure interest posted moments ago is not saved yet.
|
||||
BigDecimal posted = BigDecimal.ZERO;
|
||||
for (final SavingsAccountTransaction t : deposit.getTransactions()) {
|
||||
if (t.isInterestPostingAndNotReversed() && !t.isReversalTransaction()) {
|
||||
posted = posted.add(t.getAmount());
|
||||
}
|
||||
}
|
||||
final BigDecimal interest = interestInPayout(deposit.getId(), posted, deposit.getAccountBalance());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(deposit, year);
|
||||
if (deductee == null) {
|
||||
return null;
|
||||
}
|
||||
final Due due = dueFor(deductee, year, date, interest);
|
||||
final SavingsAccountTransaction tds = due == null ? null
|
||||
: deposit.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
return new DirectPayout(deposit.getId(), deductee, year, date, interest, kind, due, tds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the tracking row and the deduction for a direct payout, once the closing withdrawal has been
|
||||
* saved, and marks the deduction as already posted so the caller's own journal run does not post it a
|
||||
* second time - the withdrawal posted it.
|
||||
*/
|
||||
public void recordDirectPayout(final DirectPayout payout, final SavingsAccountTransaction withdrawal,
|
||||
final Set<Long> alreadyPostedTransactionIds) {
|
||||
if (payout == null || withdrawal == null || withdrawal.getId() == null) {
|
||||
return;
|
||||
}
|
||||
final Long receiptId = insertReceipt(payout.deductee(), payout.accountId(), withdrawal.getId(), payout.accountId(), payout.kind(),
|
||||
payout.year(), payout.date(), payout.interest());
|
||||
if (payout.tds() != null && payout.tds().getId() != null) {
|
||||
recordDeduction(payout.deductee(), payout.accountId(), payout.tds().getId(), payout.accountId(), receiptId, payout.year(),
|
||||
payout.date(), payout.due());
|
||||
if (alreadyPostedTransactionIds != null) {
|
||||
alreadyPostedTransactionIds.add(payout.tds().getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What {@link #deductOnDirectPayout} worked out, held until the withdrawal is saved. */
|
||||
public record DirectPayout(Long accountId, Long deductee, Year year, LocalDate date, BigDecimal interest, String kind, Due due,
|
||||
SavingsAccountTransaction tds) {}
|
||||
|
||||
/** TDS due now, and what it was worked out on. */
|
||||
public record Due(BigDecimal amount, BigDecimal ratePercent, BigDecimal liableInterest, TaxComponent component) {}
|
||||
|
||||
/** One Indian financial year. */
|
||||
public record Year(LocalDate start, LocalDate end, String label) {
|
||||
|
||||
static Year of(final LocalDate date) {
|
||||
final int startYear = TdsSql.fyStartYear(date);
|
||||
return new Year(LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31), TdsSql.fyLabel(startYear));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTimeDeposit(final SavingsAccount account) {
|
||||
final DepositAccountType type = account.depositAccountType();
|
||||
return type != null && (type.isFixedDeposit() || type.isRecurringDeposit());
|
||||
}
|
||||
|
||||
private Long deducteeOf(final SavingsAccount deposit, final Year year) {
|
||||
return jdbcTemplate.queryForObject("SELECT " + TdsSql.deducteeOf("sa") + " FROM m_savings_account sa WHERE sa.id = ?", Long.class,
|
||||
year.start(), deposit.getId());
|
||||
}
|
||||
|
||||
private Long insertReceipt(final Long deductee, final Long receivingAccountId, final Long txnId, final Long sourceAccountId,
|
||||
final String kind, final Year year, final LocalDate date, final BigDecimal interest) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tds_interest_receipt (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id,
|
||||
kind, fy_label, received_on, interest_amount, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, now()) RETURNING id
|
||||
""", Long.class, deductee, receivingAccountId, txnId, sourceAccountId, kind, year.label(), date, interest);
|
||||
}
|
||||
|
||||
private void recordDeduction(final Long deductee, final Long accountId, final Long txnId, final Long sourceAccountId,
|
||||
final Long receiptId, final Year year, final LocalDate date, final Due due) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_deduction (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, receipt_id,
|
||||
fy_label, deducted_on, amount, rate_percentage, interest_base, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
|
||||
""", deductee, accountId, txnId, sourceAccountId, receiptId, year.label(), date, due.amount(), due.ratePercent(),
|
||||
due.liableInterest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The TDS due for the deductee now, or null when nothing is.
|
||||
*
|
||||
* @param extraInterest
|
||||
* interest received on {@code date} that is not on record yet
|
||||
*/
|
||||
private Due dueFor(final Long deductee, final Year year, final LocalDate date, final BigDecimal extraInterest) {
|
||||
final List<Map<String, Object>> cfgRows = jdbcTemplate.queryForList("""
|
||||
SELECT threshold_general, threshold_senior, rate_pan_percentage, rate_no_pan_percentage, round_to
|
||||
FROM m_tds_fy_config WHERE fy_label = ?
|
||||
""", year.label());
|
||||
if (cfgRows.isEmpty()) {
|
||||
// Tracked, but not deducted: the TDS screens show the same gap and the administrator fixes it there.
|
||||
log.warn("No TDS configuration for FY {}; interest for client {} tracked but not taxed", year.label(), deductee);
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> cfg = cfgRows.get(0);
|
||||
|
||||
final List<LocalDate> accepted = jdbcTemplate.queryForList("""
|
||||
SELECT accepted_on FROM m_tds_declaration
|
||||
WHERE client_id = ? AND fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", LocalDate.class, deductee, year.label());
|
||||
final LocalDate shieldedFrom = accepted.isEmpty() ? year.end().plusDays(1) : accepted.get(0);
|
||||
|
||||
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0) AS interest,
|
||||
COALESCE(SUM(CASE WHEN r.received_on < ? THEN r.interest_amount END), 0) AS liable
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.deductee_client_id = ? AND r.fy_label = ? AND r.received_on <= ?
|
||||
""", shieldedFrom, deductee, year.label(), date);
|
||||
final BigDecimal yearInterest = ((BigDecimal) totals.get("interest")).add(extraInterest);
|
||||
final BigDecimal liableInterest = ((BigDecimal) totals.get("liable"))
|
||||
.add(date.isBefore(shieldedFrom) ? extraInterest : BigDecimal.ZERO);
|
||||
final BigDecimal deducted = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(d.amount), 0)
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.deductee_client_id = ? AND d.fy_label = ?
|
||||
""", BigDecimal.class, deductee, year.label());
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", deductee);
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), year.end());
|
||||
final BigDecimal threshold = (BigDecimal) cfg.get(senior ? "threshold_senior" : "threshold_general");
|
||||
if (yearInterest.compareTo(threshold) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final boolean panOk = TdsSql.isWellFormedPan((String) person.get("pan"));
|
||||
final BigDecimal ratePercent = (BigDecimal) cfg.get(panOk ? "rate_pan_percentage" : "rate_no_pan_percentage");
|
||||
final int roundTo = ((Number) cfg.get("round_to")).intValue();
|
||||
final BigDecimal liability = TdsSql.roundToMultiple(liableInterest.multiply(ratePercent).movePointLeft(2), roundTo);
|
||||
final BigDecimal already = deducted == null ? BigDecimal.ZERO : deducted;
|
||||
final BigDecimal amount = liability.subtract(already);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
log.info("TDS {} due for client {} (FY {}: interest {}, liability {}, already deducted {})", amount, deductee, year.label(),
|
||||
yearInterest, liability, already);
|
||||
return new Due(amount, ratePercent, liableInterest, tdsComponent(ratePercent));
|
||||
}
|
||||
|
||||
private BigDecimal postedFromDatabase(final Long accountId) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
|
||||
""", BigDecimal.class, accountId, TdsSql.INTEREST_POSTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* The interest inside a maturity or closure payout: interest posted on the deposit that has not been
|
||||
* received already - through the interest sweep or an earlier payout. Capped at the payout.
|
||||
*
|
||||
* <p>
|
||||
* On a premature closure the months already swept to savings stand as they were; only the interest
|
||||
* for the days since is paid out now, and only that is counted.
|
||||
*/
|
||||
private BigDecimal interestInPayout(final Long sourceAccountId, final BigDecimal posted, final BigDecimal payout) {
|
||||
final BigDecimal swept = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(att.amount), 0)
|
||||
FROM m_account_transfer_transaction att
|
||||
JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id
|
||||
WHERE d.from_savings_account_id = ? AND d.transfer_type = ? AND att.is_reversed = false
|
||||
""", BigDecimal.class, sourceAccountId, AccountTransferType.INTEREST_TRANSFER.getValue());
|
||||
final BigDecimal paidOut = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0)
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.source_account_id = ? AND r.kind <> 'INTEREST'
|
||||
""", BigDecimal.class, sourceAccountId);
|
||||
final BigDecimal unpaid = posted.subtract(swept).subtract(paidOut);
|
||||
if (unpaid.compareTo(BigDecimal.ZERO) <= 0 || payout == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return unpaid.min(payout);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tax component the deduction is booked through, created on first use and re-pointed whenever
|
||||
* the TDS payable account on the configuration screen changes.
|
||||
*/
|
||||
private TaxComponent tdsComponent(final BigDecimal ratePercent) {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.tds.payable.gl.not.configured",
|
||||
"TDS is due on this interest but no TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
final Long glId = gl.get(0);
|
||||
final List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM m_tax_component WHERE name = ? ORDER BY id", Long.class,
|
||||
TDS_COMPONENT_NAME);
|
||||
final Long id;
|
||||
boolean repointed = false;
|
||||
if (existing.isEmpty()) {
|
||||
id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tax_component (name, percentage, credit_account_type_enum, credit_account_id, start_date,
|
||||
createdby_id, created_date, lastmodifiedby_id, lastmodified_date)
|
||||
SELECT ?, ?, 2, ?, DATE '2000-01-01', MIN(u.id), now(), MIN(u.id), now() FROM m_appuser u
|
||||
RETURNING id
|
||||
""", Long.class, TDS_COMPONENT_NAME, ratePercent, glId);
|
||||
} else {
|
||||
id = existing.get(0);
|
||||
repointed = jdbcTemplate.update("UPDATE m_tax_component SET credit_account_type_enum = 2, credit_account_id = ? WHERE id = ?"
|
||||
+ " AND (credit_account_id IS DISTINCT FROM ? OR credit_account_type_enum IS DISTINCT FROM 2)", glId, id, glId) > 0;
|
||||
}
|
||||
final TaxComponent component = taxComponentRepository.findById(id).orElseThrow();
|
||||
// Written behind JPA's back, so a copy already in this session would still credit the old account.
|
||||
if (repointed) {
|
||||
entityManager.refresh(component);
|
||||
}
|
||||
return component;
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -29,8 +29,6 @@ public interface TdsInterestReadPlatformService {
|
||||
* @param financialYear
|
||||
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
|
||||
* 31 Mar 2027
|
||||
* @param clientId
|
||||
* one customer only, or null for everyone in the user's scope
|
||||
*/
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear, Long clientId);
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
|
||||
}
|
||||
|
||||
+65
-122
@@ -27,8 +27,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeclarationData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
|
||||
@@ -37,8 +35,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reads the interest the bank actually credited, and the TDS it actually deducted, so the register is
|
||||
* worked out from the ledger rather than from a balance and a rate.
|
||||
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
|
||||
* rather than from a balance and a rate.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -46,150 +44,109 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Every account, grouped under the client whose PAN carries its deduction, with what counts for TDS
|
||||
* inside the year.
|
||||
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
|
||||
*
|
||||
* <p>
|
||||
* On a fixed or recurring deposit, "interest" is the FD/RD interest that reached the customer's
|
||||
* savings account (m_tds_interest_receipt) and "deductions" the TDS debited there for it
|
||||
* (m_tds_deduction) - TDS is tracked and taken where the interest is received, and the deposit
|
||||
* itself is never debited. Interest still sitting in a deposit is not counted until it is paid out.
|
||||
* Rows whose savings transaction has been reversed are left out: reversed money was never received.
|
||||
* This single condition is what makes the figure an interest figure. A deposit is the
|
||||
* customer's own money arriving and is not income; a withdrawal and a fee are not income
|
||||
* either. Only what the bank itself credited as interest can be deducted against, so the
|
||||
* transaction type is filtered here rather than being inferred later from an amount.
|
||||
*
|
||||
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
|
||||
*/
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
|
||||
/**
|
||||
* Every deposit the customer holds, with the interest credited to each inside the year.
|
||||
*
|
||||
* <p>
|
||||
* On a savings account, "interest" is its own interest postings. The charge does not reach them, and
|
||||
* the caller keeps them out of the aggregate - but the branch still has to see them.
|
||||
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
|
||||
* keeps their interest out of the aggregate - but the branch still has to see that the customer
|
||||
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
|
||||
*
|
||||
* <p>
|
||||
* Scoped to the signed-in user's office and the offices below it, on the deductee's office. A branch
|
||||
* login therefore receives only its own customers. Each customer still arrives with every one of
|
||||
* their deposits, wherever held, because the threshold is tested on the bank as a whole.
|
||||
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
|
||||
* deduct tax on interest the customer never received.
|
||||
*/
|
||||
private static final String SQL = """
|
||||
WITH acc AS (
|
||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.activatedon_date, sa.account_status,
|
||||
sa.client_id AS holder_id, %s AS deductee_id
|
||||
FROM m_savings_account sa
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
),
|
||||
lines AS (
|
||||
SELECT t.savings_account_id AS account_id, 'C' AS kind, t.transaction_date AS d, t.amount AS amt
|
||||
FROM m_savings_account_transaction t
|
||||
JOIN m_savings_account s ON s.id = t.savings_account_id AND s.deposit_type_enum = 100
|
||||
WHERE t.transaction_type_enum = ? AND t.is_reversed = false AND t.transaction_date BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT r.source_account_id, 'C', r.received_on, r.interest_amount
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.received_on BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT dd.source_account_id, 'D', dd.deducted_on, dd.amount
|
||||
FROM m_tds_deduction dd
|
||||
JOIN m_savings_account_transaction t ON t.id = dd.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE dd.deducted_on BETWEEN ? AND ?
|
||||
)
|
||||
SELECT c.id AS client_id,
|
||||
c.account_no AS customer_id,
|
||||
c.display_name AS name,
|
||||
c.date_of_birth AS date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan,
|
||||
k.pan_number AS pan,
|
||||
k.form15_type AS form15_type,
|
||||
o.name AS branch_name,
|
||||
a.id AS account_id,
|
||||
a.account_no AS account_no,
|
||||
a.activatedon_date AS opened_on,
|
||||
a.account_status AS account_status,
|
||||
a.holder_id AS holder_id,
|
||||
h.display_name AS holder_name,
|
||||
CASE a.deposit_type_enum
|
||||
sa.id AS account_id,
|
||||
sa.account_no AS account_no,
|
||||
sa.activatedon_date AS opened_on,
|
||||
CASE sa.deposit_type_enum
|
||||
WHEN 100 THEN 'depositAccountType.savingsDeposit'
|
||||
WHEN 200 THEN 'depositAccountType.fixedDeposit'
|
||||
WHEN 300 THEN 'depositAccountType.recurringDeposit'
|
||||
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
|
||||
l.kind AS line_kind,
|
||||
l.d AS txn_date,
|
||||
l.amt AS txn_amount
|
||||
FROM acc a
|
||||
JOIN m_client c ON c.id = a.deductee_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
JOIN m_client h ON h.id = a.holder_id
|
||||
LEFT JOIN lines l ON l.account_id = a.id
|
||||
WHERE o.hierarchy LIKE ?
|
||||
AND (CAST(? AS BIGINT) IS NULL OR c.id = CAST(? AS BIGINT))
|
||||
ORDER BY c.display_name, a.account_no, l.d
|
||||
""".formatted(TdsSql.deducteeOf("sa"));
|
||||
t.transaction_date AS credit_date,
|
||||
t.amount AS credit_amount
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.savings_account_id = sa.id
|
||||
AND t.transaction_type_enum = ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_date >= ?
|
||||
AND t.transaction_date <= ?
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
ORDER BY c.display_name, sa.account_no, t.transaction_date
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear, final Long onlyClientId) {
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
|
||||
// The Indian financial year, 1 April to 31 March.
|
||||
final LocalDate from = LocalDate.of(financialYear, 4, 1);
|
||||
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
|
||||
final java.sql.Date f = java.sql.Date.valueOf(from);
|
||||
final java.sql.Date t = java.sql.Date.valueOf(to);
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||
|
||||
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
|
||||
|
||||
jdbcTemplate.query(SQL, rs -> {
|
||||
final long clientId = rs.getLong("client_id");
|
||||
final Deductee deductee = byClient.computeIfAbsent(clientId,
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), readString(rs, "pan"),
|
||||
readDate(rs, "date_of_birth"), readString(rs, "branch_name")));
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
|
||||
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
|
||||
readString(rs, "form15_type")));
|
||||
|
||||
final long accountId = rs.getLong("account_id");
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId,
|
||||
id -> new Account(id, readString(rs, "account_no"), readString(rs, "deposit_type_code"), readLong(rs, "holder_id"),
|
||||
readString(rs, "holder_name"), readString(rs, "account_status"), readDate(rs, "opened_on")));
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
|
||||
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
|
||||
|
||||
// Null where the outer join found nothing inside the year: the account is still listed, it
|
||||
// simply earned nothing in the window.
|
||||
final java.sql.Date date = rs.getDate("txn_date");
|
||||
if (date != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("txn_amount");
|
||||
final TdsInterestCreditData line = new TdsInterestCreditData(date.toLocalDate(), amount, account.accountNo);
|
||||
if ("D".equals(rs.getString("line_kind"))) {
|
||||
account.deductions.add(line);
|
||||
} else {
|
||||
account.credits.add(line);
|
||||
}
|
||||
// Null where the outer join found no posting inside the year: the account is still
|
||||
// listed, it simply earned nothing in the window.
|
||||
final java.sql.Date creditDate = rs.getDate("credit_date");
|
||||
if (creditDate != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("credit_amount");
|
||||
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
|
||||
}
|
||||
}, f, TdsSql.INTEREST_POSTING, f, t, f, t, f, t, hierarchy, onlyClientId, onlyClientId);
|
||||
|
||||
final Map<Long, List<TdsDeclarationData>> declarations = declarationsFor(TdsSql.fyLabel(financialYear));
|
||||
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
|
||||
|
||||
final List<TdsDeducteeData> result = new ArrayList<>();
|
||||
for (final Deductee d : byClient.values()) {
|
||||
final List<TdsAccountData> accounts = new ArrayList<>();
|
||||
for (final Account a : d.accounts.values()) {
|
||||
accounts.add(new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, d.clientId, a.holderId, a.holderName, a.accountStatus,
|
||||
a.openedOn, a.credits, a.deductions));
|
||||
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
|
||||
// to decide whose PAN carries the deduction. Fineract does not model it on the
|
||||
// savings account, so it is left null and the caller falls back to the account
|
||||
// holder rather than inventing a joint holder who may not exist.
|
||||
accounts.add(
|
||||
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
|
||||
}
|
||||
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
|
||||
declarations.getOrDefault(d.clientId, List.of()), accounts));
|
||||
d.form15Type, accounts));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, List<TdsDeclarationData>> declarationsFor(final String fy) {
|
||||
final Map<Long, List<TdsDeclarationData>> out = new LinkedHashMap<>();
|
||||
jdbcTemplate.query("""
|
||||
SELECT id, client_id, fy_label, form_type, uin, accepted_on, estimated_income
|
||||
FROM m_tds_declaration
|
||||
WHERE fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", rs -> {
|
||||
final TdsDeclarationData d = new TdsDeclarationData(rs.getLong("id"), rs.getLong("client_id"), rs.getString("fy_label"),
|
||||
rs.getString("form_type"), rs.getString("uin"), readDate(rs, "accepted_on"), rs.getBigDecimal("estimated_income"),
|
||||
false, null, null);
|
||||
out.computeIfAbsent(d.clientId(), k -> new ArrayList<>()).add(d);
|
||||
}, fy);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String readString(final ResultSet rs, final String column) {
|
||||
try {
|
||||
return rs.getString(column);
|
||||
@@ -198,15 +155,6 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
}
|
||||
}
|
||||
|
||||
private static Long readLong(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final long v = rs.getLong(column);
|
||||
return rs.wasNull() ? null : v;
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate readDate(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final java.sql.Date value = rs.getDate(column);
|
||||
@@ -225,15 +173,18 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final String pan;
|
||||
private final LocalDate dateOfBirth;
|
||||
private final String branchName;
|
||||
private final String form15Type;
|
||||
private final Map<Long, Account> accounts = new LinkedHashMap<>();
|
||||
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, String branchName) {
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type) {
|
||||
this.clientId = clientId;
|
||||
this.customerId = customerId;
|
||||
this.name = name;
|
||||
this.pan = pan;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.branchName = branchName;
|
||||
this.form15Type = form15Type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,21 +193,13 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final Long id;
|
||||
private final String accountNo;
|
||||
private final String depositTypeCode;
|
||||
private final Long holderId;
|
||||
private final String holderName;
|
||||
private final String accountStatus;
|
||||
private final LocalDate openedOn;
|
||||
private final List<TdsInterestCreditData> credits = new ArrayList<>();
|
||||
private final List<TdsInterestCreditData> deductions = new ArrayList<>();
|
||||
|
||||
private Account(Long id, String accountNo, String depositTypeCode, Long holderId, String holderName, String accountStatus,
|
||||
LocalDate openedOn) {
|
||||
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.depositTypeCode = depositTypeCode;
|
||||
this.holderId = holderId;
|
||||
this.holderName = holderName;
|
||||
this.accountStatus = accountStatus;
|
||||
this.openedOn = openedOn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The TDS rules both the deduction and the report must agree on, kept in one place so the passbook
|
||||
* and the register cannot drift apart.
|
||||
*/
|
||||
public final class TdsSql {
|
||||
|
||||
public static final int INTEREST_POSTING = 3;
|
||||
public static final int WITHHOLD_TAX = 18;
|
||||
public static final int FIXED_DEPOSIT = 200;
|
||||
public static final int RECURRING_DEPOSIT = 300;
|
||||
|
||||
/** A well-formed PAN with a recognised holder type - the same test the screens apply. */
|
||||
private static final Pattern PAN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$");
|
||||
private static final String PAN_HOLDER_TYPES = "ABCFGHJLPT";
|
||||
|
||||
private TdsSql() {}
|
||||
|
||||
/**
|
||||
* The client whose PAN carries the deduction for an account, as SQL over the alias given.
|
||||
*
|
||||
* <ul>
|
||||
* <li>A minor's account follows the guardian - the interest is clubbed into the guardian's income.
|
||||
* Minor means under 18 on the first day of the year, so one year has one deductee.</li>
|
||||
* <li>A joint account follows the holder flagged primary - one deposit, one deductee.</li>
|
||||
* <li>Anything else follows the account holder.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Takes one bind parameter: the financial year's first day.
|
||||
*/
|
||||
public static String deducteeOf(final String sa) {
|
||||
return """
|
||||
(CASE
|
||||
WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'MINOR' AND %1$s.guardian_client_id IS NOT NULL
|
||||
AND (%1$s.minor_date_of_birth IS NULL
|
||||
OR %1$s.minor_date_of_birth > CAST(? AS DATE) - INTERVAL '18 years')
|
||||
THEN %1$s.guardian_client_id
|
||||
WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'JOINT'
|
||||
THEN COALESCE((SELECT MIN(j.client_id) FROM m_savings_account_joint_holders j
|
||||
WHERE j.savings_account_id = %1$s.id AND j.is_primary = true), %1$s.client_id)
|
||||
ELSE %1$s.client_id
|
||||
END)""".formatted(sa);
|
||||
}
|
||||
|
||||
public static boolean isWellFormedPan(final String pan) {
|
||||
final String p = pan == null ? "" : pan.trim().toUpperCase();
|
||||
return PAN.matcher(p).matches() && PAN_HOLDER_TYPES.indexOf(p.charAt(3)) >= 0;
|
||||
}
|
||||
|
||||
/** Age reached by the last day of the year: 60 at any point in it makes a senior citizen. */
|
||||
public static boolean isSeniorInFy(final LocalDate dateOfBirth, final LocalDate fyEnd) {
|
||||
return dateOfBirth != null && !dateOfBirth.plusYears(60).isAfter(fyEnd);
|
||||
}
|
||||
|
||||
public static int fyStartYear(final LocalDate date) {
|
||||
return date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
}
|
||||
|
||||
/** '2026-27' for any date from 1 Apr 2026 to 31 Mar 2027. */
|
||||
public static String fyLabel(final int startYear) {
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
|
||||
public static BigDecimal roundToMultiple(final BigDecimal value, final int to) {
|
||||
if (to <= 1) {
|
||||
return value.setScale(0, RoundingMode.HALF_UP);
|
||||
}
|
||||
final BigDecimal step = BigDecimal.valueOf(to);
|
||||
return value.divide(step, 0, RoundingMode.HALF_UP).multiply(step);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user