FINERACT-2081: Payments after loan charge - payment allocation

This commit is contained in:
Marta Jankovics
2024-09-25 10:31:08 +02:00
committed by Adam Saghy
parent 2d0c8db993
commit 31c9079ad9
17 changed files with 746 additions and 228 deletions
@@ -704,8 +704,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.charges.add(loanCharge);
this.summary = updateSummaryWithTotalFeeChargesDueAtDisbursement(deriveSumTotalOfChargesDueAtDisbursement());
// store Id's of existing loan transactions and existing reversed loan
// transactions
// store Id's of existing loan transactions and existing reversed loan transactions
final SingleLoanChargeRepaymentScheduleProcessingWrapper wrapper = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
wrapper.reprocess(getCurrency(), getDisbursementDate(), getRepaymentScheduleInstallments(), loanCharge);
updateLoanSummaryDerivedFields();
@@ -721,7 +720,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
changedTransactionDetail = loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(getDisbursementDate(),
allNonContraTransactionsPostDisbursement, getCurrency(), getRepaymentScheduleInstallments(), getActiveCharges());
for (final Map.Entry<Long, LoanTransaction> mapEntry : changedTransactionDetail.getNewTransactionMappings().entrySet()) {
mapEntry.getValue().updateLoan(this);
}
this.loanTransactions.addAll(changedTransactionDetail.getNewTransactionMappings().values());
@@ -729,6 +727,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return changedTransactionDetail;
}
public ChangedTransactionDetail reprocessTransactionsWithPostTransactionChecks(LocalDate transactionDate) {
ChangedTransactionDetail changedDetail = reprocessTransactions();
doPostLoanTransactionChecks(transactionDate, loanLifecycleStateMachine);
return changedDetail;
}
/**
* Creates a loanTransaction for "Apply Charge Event" with transaction date set to "suppliedTransactionDate". The
* newly created transaction is also added to the Loan on which this method is called.
@@ -2499,7 +2503,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
private boolean doPostLoanTransactionChecks(final LocalDate transactionDate,
final LoanLifecycleStateMachine loanLifecycleStateMachine) {
boolean statusChanged = false;
boolean isOverpaid = getTotalOverpaid() != null && getTotalOverpaid().compareTo(BigDecimal.ZERO) > 0;
boolean isOverpaid = MathUtil.isGreaterThanZero(totalOverpaid);
if (isOverpaid) {
// FIXME - kw - update account balance to negative amount.
handleLoanOverpayment(transactionDate, loanLifecycleStateMachine);
@@ -2510,7 +2514,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} else {
loanLifecycleStateMachine.transition(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, this);
}
if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) {
if (MathUtil.isEmpty(totalOverpaid)) {
this.overpaidOnDate = null;
}
return statusChanged;
@@ -2526,8 +2530,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.actualMaturityDate = transactionDate;
loanLifecycleStateMachine.transition(LoanEvent.REPAID_IN_FULL, this);
} else if (LoanStatus.fromInt(this.loanStatus).isOverpaid()) {
if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) {
} else if (getStatus().isOverpaid()) {
if (MathUtil.isEmpty(totalOverpaid)) {
this.overpaidOnDate = null;
}
loanLifecycleStateMachine.transition(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, this);
@@ -4498,7 +4502,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
loanTransaction.updateOutstandingLoanBalance(MathUtil.negativeToZero(outstanding.getAmount()));
} else if (loanTransaction.isChargeback() || loanTransaction.isCreditBalanceRefund()) {
Money transactionOutstanding = loanTransaction.getPrincipalPortion(getCurrency());
if (!loanTransaction.getOverPaymentPortion(getCurrency()).isZero()) {
if (loanTransaction.isOverPaid()) {
// in case of advanced payment strategy and creditAllocations the full amount is recognized first
if (this.getCreditAllocationRules() != null && this.getCreditAllocationRules().size() > 0) {
Money payedPrincipal = loanTransaction.getLoanTransactionToRepaymentScheduleMappings().stream() //
@@ -4792,7 +4796,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateLoanSummaryDerivedFields();
if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) {
if (MathUtil.isEmpty(totalOverpaid)) {
this.overpaidOnDate = null;
this.closedOnDate = newCreditBalanceRefundTransaction.getTransactionDate();
defaultLoanLifecycleStateMachine.transition(LoanEvent.LOAN_CREDIT_BALANCE_REFUND, this);
@@ -5497,12 +5501,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return getLoanTransactions().stream().filter(predicate).toList();
}
public LoanTransaction getLoanTransaction(Predicate<LoanTransaction> predicate) {
return getLoanTransactions().stream().filter(predicate).findFirst().orElse(null);
}
public LoanTransaction findChargedOffTransaction() {
return getLoanTransactions().stream() //
.filter(LoanTransaction::isNotReversed) //
.filter(LoanTransaction::isChargeOff) //
.findFirst() //
.orElse(null);
return getLoanTransaction(e -> e.isNotReversed() && e.isChargeOff());
}
public void handleMaturityDateActivate() {
@@ -38,9 +38,11 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -303,6 +305,13 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
return newTransaction;
}
public LoanTransaction copyTransactionPropertiesAndMappings() {
LoanTransaction newTransaction = copyTransactionProperties(this);
newTransaction.updateLoanTransactionToRepaymentScheduleMappings(loanTransactionToRepaymentScheduleMappings);
newTransaction.updateLoanChargePaidMappings(loanChargesPaid);
return newTransaction;
}
public static LoanTransaction accrueLoanCharge(final Loan loan, final Office office, final Money amount, final LocalDate applyDate,
final Money feeCharges, final Money penaltyCharges, final ExternalId externalId) {
final LoanTransaction applyCharge = new LoanTransaction(loan, office, LoanTransactionType.ACCRUAL, amount.getAmount(), applyDate,
@@ -920,37 +929,84 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
public void updateLoanTransactionToRepaymentScheduleMappings(final Collection<LoanTransactionToRepaymentScheduleMapping> mappings) {
Collection<LoanTransactionToRepaymentScheduleMapping> retainMappings = new ArrayList<>();
for (LoanTransactionToRepaymentScheduleMapping updatedrepaymentScheduleMapping : mappings) {
updateMapingDetail(retainMappings, updatedrepaymentScheduleMapping);
for (LoanTransactionToRepaymentScheduleMapping updatedMapping : mappings) {
updateMappingDetail(retainMappings, updatedMapping, true);
}
this.loanTransactionToRepaymentScheduleMappings.retainAll(retainMappings);
}
private boolean updateMapingDetail(final Collection<LoanTransactionToRepaymentScheduleMapping> retainMappings,
final LoanTransactionToRepaymentScheduleMapping updatedrepaymentScheduleMapping) {
public void addLoanTransactionToRepaymentScheduleMappings(final Collection<LoanTransactionToRepaymentScheduleMapping> updatedMappings) {
for (LoanTransactionToRepaymentScheduleMapping updatedMapping : updatedMappings) {
updateMappingDetail(null, updatedMapping, false);
}
}
private boolean updateMappingDetail(final Collection<LoanTransactionToRepaymentScheduleMapping> retainMappings,
final LoanTransactionToRepaymentScheduleMapping updatedMapping, boolean overwrite) {
boolean isMappingUpdated = false;
for (LoanTransactionToRepaymentScheduleMapping repaymentScheduleMapping : this.loanTransactionToRepaymentScheduleMappings) {
if (updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId() != null
&& repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate()
.equals(updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate())
&& updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId()
.equals(repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId())) {
repaymentScheduleMapping.setComponents(updatedrepaymentScheduleMapping.getPrincipalPortion(),
updatedrepaymentScheduleMapping.getInterestPortion(), updatedrepaymentScheduleMapping.getFeeChargesPortion(),
updatedrepaymentScheduleMapping.getPenaltyChargesPortion());
LoanRepaymentScheduleInstallment updatedInstallment = updatedMapping.getLoanRepaymentScheduleInstallment();
for (LoanTransactionToRepaymentScheduleMapping existingMapping : this.loanTransactionToRepaymentScheduleMappings) {
LoanRepaymentScheduleInstallment existingInstallment = existingMapping.getLoanRepaymentScheduleInstallment();
if (DateUtils.isEqual(existingInstallment.getDueDate(), updatedInstallment.getDueDate()) && updatedInstallment.getId() != null
&& updatedInstallment.getId().equals(existingInstallment.getId())) {
if (overwrite) {
existingMapping.setComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(),
updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion());
} else {
existingMapping.updateComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(),
updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion());
}
isMappingUpdated = true;
retainMappings.add(repaymentScheduleMapping);
if (retainMappings != null) {
retainMappings.add(existingMapping);
}
break;
}
}
if (!isMappingUpdated) {
updatedrepaymentScheduleMapping.setLoanTransaction(this);
this.loanTransactionToRepaymentScheduleMappings.add(updatedrepaymentScheduleMapping);
retainMappings.add(updatedrepaymentScheduleMapping);
LoanTransactionToRepaymentScheduleMapping newMapping = LoanTransactionToRepaymentScheduleMapping.createFrom(this,
updatedInstallment, null, null, null, null);
newMapping.setComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(),
updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion());
this.loanTransactionToRepaymentScheduleMappings.add(newMapping);
if (retainMappings != null) {
retainMappings.add(newMapping);
}
}
return isMappingUpdated;
}
public void updateLoanChargePaidMappings(final Collection<LoanChargePaidBy> updatedMappings) {
Collection<LoanChargePaidBy> retainMappings = new ArrayList<>();
for (LoanChargePaidBy updatedMapping : updatedMappings) {
updateLoanChargePaid(retainMappings, updatedMapping);
}
this.loanChargesPaid.retainAll(retainMappings);
}
private boolean updateLoanChargePaid(final Collection<LoanChargePaidBy> retainMappings, final LoanChargePaidBy updatedMapping) {
boolean updated = false;
LoanCharge updatedCharge = updatedMapping.getLoanCharge();
Integer updatedInstallment = updatedMapping.getInstallmentNumber();
for (LoanChargePaidBy existingMapping : loanChargesPaid) {
LoanCharge existingCharge = existingMapping.getLoanCharge();
Integer existingInstallment = existingMapping.getInstallmentNumber();
if (existingCharge.equals(updatedCharge)
&& (existingInstallment == null ? updatedInstallment == null : existingInstallment.equals(updatedInstallment))) {
existingMapping.setAmount(updatedMapping.getAmount());
updated = true;
retainMappings.add(existingMapping);
break;
}
}
if (!updated) {
LoanChargePaidBy newMapping = new LoanChargePaidBy(this, updatedCharge, updatedMapping.getAmount(), updatedInstallment);
this.loanChargesPaid.add(newMapping);
retainMappings.add(newMapping);
}
return updated;
}
public Set<LoanTransactionToRepaymentScheduleMapping> getLoanTransactionToRepaymentScheduleMappings() {
return this.loanTransactionToRepaymentScheduleMappings;
}
@@ -989,18 +1045,21 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
}
public boolean hasLoanTransactionRelations() {
return (loanTransactionRelations != null && loanTransactionRelations.size() > 0);
}
public boolean hasChargebackLoanTransactionRelations() {
return (loanTransactionRelations != null
&& loanTransactionRelations.stream().anyMatch(e -> LoanTransactionRelationTypeEnum.CHARGEBACK.equals(e.getRelationType())));
return !loanTransactionRelations.isEmpty();
}
public Set<LoanTransactionRelation> getLoanTransactionRelations() {
return loanTransactionRelations;
}
public List<LoanTransactionRelation> getLoanTransactionRelations(Predicate<LoanTransactionRelation> predicate) {
return loanTransactionRelations.stream().filter(predicate).toList();
}
public boolean hasChargebackLoanTransactionRelations() {
return !getLoanTransactionRelations(e -> LoanTransactionRelationTypeEnum.CHARGEBACK.equals(e.getRelationType())).isEmpty();
}
public void copyLoanTransactionRelations(Set<LoanTransactionRelation> sourceLoanTransactionRelations) {
for (LoanTransactionRelation existingLoanTransactionRelation : sourceLoanTransactionRelations) {
loanTransactionRelations.add(new LoanTransactionRelation(this, existingLoanTransactionRelation.getToTransaction(),
@@ -1055,6 +1114,10 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
return chargeRefundChargeType;
}
public boolean isOverPaid() {
return MathUtil.isGreaterThanZero(overPaymentPortion);
}
// TODO missing hashCode(), equals(Object obj), but probably OK as long as
// this is never stored in a Collection.
}
@@ -24,8 +24,10 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -77,15 +79,11 @@ public class LoanTransactionToRepaymentScheduleMapping extends AbstractPersistab
final Money feeChargesPortion, final Money penaltyChargesPortion) {
return new LoanTransactionToRepaymentScheduleMapping(loanTransaction, installment, defaultToNullIfZero(principalPortion),
defaultToNullIfZero(interestPortion), defaultToNullIfZero(feeChargesPortion), defaultToNullIfZero(penaltyChargesPortion),
defaultToNullIfZero(principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion)));
defaultToNullIfZero(MathUtil.plus(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion)));
}
private static BigDecimal defaultToNullIfZero(final Money value) {
BigDecimal result = value.getAmount();
if (value.isZero()) {
result = null;
}
return result;
return (value == null || value.isZero()) ? null : value.getAmount();
}
private BigDecimal defaultToZeroIfNull(final BigDecimal value) {
@@ -100,10 +98,15 @@ public class LoanTransactionToRepaymentScheduleMapping extends AbstractPersistab
return this.installment;
}
public void updateComponents(final Money principal, final Money interest, final Money feeCharges, final Money penaltyCharges) {
final MonetaryCurrency currency = principal.getCurrency();
this.principalPortion = defaultToNullIfZero(getPrincipalPortion(currency).plus(principal));
this.interestPortion = defaultToNullIfZero(getInterestPortion(currency).plus(interest));
public void updateComponents(@NotNull Money principal, @NotNull Money interest, @NotNull Money feeCharges,
@NotNull Money penaltyCharges) {
updateComponents(principal.getAmount(), interest.getAmount(), feeCharges.getAmount(), penaltyCharges.getAmount());
}
void updateComponents(final BigDecimal principal, final BigDecimal interest, final BigDecimal feeCharges,
final BigDecimal penaltyCharges) {
this.principalPortion = MathUtil.zeroToNull(MathUtil.add(getPrincipalPortion(), principal));
this.interestPortion = MathUtil.zeroToNull(MathUtil.add(getInterestPortion(), interest));
updateChargesComponents(feeCharges, penaltyCharges);
updateAmount();
}
@@ -122,10 +125,9 @@ public class LoanTransactionToRepaymentScheduleMapping extends AbstractPersistab
updateAmount();
}
private void updateChargesComponents(final Money feeCharges, final Money penaltyCharges) {
final MonetaryCurrency currency = feeCharges.getCurrency();
this.feeChargesPortion = defaultToNullIfZero(getFeeChargesPortion(currency).plus(feeCharges));
this.penaltyChargesPortion = defaultToNullIfZero(getPenaltyChargesPortion(currency).plus(penaltyCharges));
private void updateChargesComponents(final BigDecimal feeCharges, final BigDecimal penaltyCharges) {
this.feeChargesPortion = MathUtil.zeroToNull(MathUtil.add(getFeeChargesPortion(), feeCharges));
this.penaltyChargesPortion = MathUtil.zeroToNull(MathUtil.add(getPenaltyChargesPortion(), penaltyCharges));
}
public Money getPrincipalPortion(final MonetaryCurrency currency) {
@@ -28,7 +28,9 @@ import org.apache.fineract.infrastructure.core.data.EnumOptionData;
@RequiredArgsConstructor
public enum LoanScheduleProcessingType {
HORIZONTAL("Horizontal"), VERTICAL("Vertical");
HORIZONTAL("Horizontal"), //
VERTICAL("Vertical"), //
;
private final String humanReadableName;
@@ -39,4 +41,12 @@ public enum LoanScheduleProcessingType {
public EnumOptionData asEnumOptionData() {
return new EnumOptionData((long) this.ordinal(), this.name(), this.humanReadableName);
}
public boolean isHorizontal() {
return this == HORIZONTAL;
}
public boolean isVertical() {
return this == VERTICAL;
}
}
@@ -50,4 +50,8 @@ public enum PaymentAllocationTransactionType {
public static List<EnumOptionData> getValuesAsEnumOptionDataList() {
return Arrays.stream(values()).map(v -> new EnumOptionData((long) (v.ordinal() + 1), v.name(), v.getHumanReadableName())).toList();
}
public boolean isDefault() {
return this == DEFAULT;
}
}