Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -320,32 +320,24 @@ public static BigDecimal percentageOf(final BigDecimal value, final BigDecimal p
}

/**
* @param percentageOf
* @returns a minimum cap or maximum cap set on charges if the criteria fits else it returns the percentageOf if the
* amount is within min and max cap
* @param value
* @returns a minimum cap or maximum cap set on charges if the criteria fits else it returns the value if the amount
* is within min and max cap
*/
public BigDecimal minimumAndMaximumCap(final BigDecimal percentageOf) {
BigDecimal minMaxCap;
if (this.minCap != null) {
final int minimumCap = percentageOf.compareTo(this.minCap);
if (minimumCap == -1) {
minMaxCap = this.minCap;
return minMaxCap;
}
public BigDecimal minimumAndMaximumCap(final BigDecimal value) {
BigDecimal result = value;

if (this.minCap != null && value.compareTo(this.minCap) < 0) {
result = this.minCap;
}
if (this.maxCap != null) {
final int maximumCap = percentageOf.compareTo(this.maxCap);
if (maximumCap == 1) {
minMaxCap = this.maxCap;
return minMaxCap;
}
if (this.maxCap != null && value.compareTo(this.maxCap) > 0) {
result = this.maxCap;
}
minMaxCap = percentageOf;
// this will round the amount value
if (this.loan != null && minMaxCap != null) {
minMaxCap = Money.of(this.loan.getCurrency(), minMaxCap).getAmount();
if (this.loan != null && result != null) {
result = Money.of(this.loan.getCurrency(), result).getAmount();
}
return minMaxCap;

return result;
}

public BigDecimal amount() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ public void addLoanCharge(final Loan loan, final LoanCharge loanCharge) {
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmentsAfterExceptions(),
totalChargeAmt);

// Skip zero-value charges
if (isZeroCharge(loanCharge)) {
return;
}

// NOTE: must add new loan charge to set of loan charges before
// reprocessing the repayment schedule.
if (loan.getLoanCharges() == null) {
Expand Down Expand Up @@ -384,6 +389,7 @@ public Map<String, Object> update(final JsonCommand command, final BigDecimal am
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:

loanCharge.setPercentage(newValue);
loanCharge.setAmountPercentageAppliedTo(amount);
loanChargeAmount = BigDecimal.ZERO;
Expand Down Expand Up @@ -822,9 +828,9 @@ private void update(final LoanCharge loanCharge, final BigDecimal amount, final
if (numberOfRepayments == null) {
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmentsAfterExceptions();
}
loanCharge.setAmount(amount.multiply(BigDecimal.valueOf(numberOfRepayments)));
loanCharge.setAmount(loanCharge.minimumAndMaximumCap(amount.multiply(BigDecimal.valueOf(numberOfRepayments))));
} else {
loanCharge.setAmount(amount);
loanCharge.setAmount(loanCharge.minimumAndMaximumCap(amount));
}
break;
case PERCENT_OF_AMOUNT:
Expand Down Expand Up @@ -998,4 +1004,8 @@ private boolean doesLoanChargePaidByContainLoanCharge(Set<LoanChargePaidBy> loan
.anyMatch(loanChargePaidBy -> loanChargePaidBy.getLoanCharge().equals(loanCharge));
}

private boolean isZeroCharge(LoanCharge loanCharge) {
return loanCharge.getAmount() != null && loanCharge.getAmount().compareTo(BigDecimal.ZERO) == 0;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -465,15 +465,26 @@ public static final class PostAccountsTypeAccountIdRequest {

private PostAccountsTypeAccountIdRequest() {}

@Schema(example = "en")
public String locale;
@Schema(example = "dd MMMM yyyy")
public String dateFormat;
@Schema(example = "01 January 2026")
public String activatedDate;
@Schema(example = "05 May 2026")
public String requestedDate;
@Schema(example = "01 January 2026")
public String closedDate;
@Schema(description = "Can represent either number of shares or transaction IDs")
public Object requestedShares;

static final class PostAccountsRequestedShares {

private PostAccountsRequestedShares() {}

@Schema(example = "35")
public Long id;
}

public Set<PostAccountsRequestedShares> requestedShares;
}

@Schema(description = "PostAccountsTypeAccountIdResponse")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ public static final class PostClientsClientIdChargesRequest {

private PostClientsClientIdChargesRequest() {}

@Schema(example = "100")
public Integer amount;
@Schema(example = "100.25")
public BigDecimal amount;
@Schema(example = "226")
public Long chargeId;
@Schema(example = "dd MMMM yyyy")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,9 @@ protected ClientCharge() {
//
}

public static ClientCharge createNew(final Client client, final Charge charge, final JsonCommand command) {
BigDecimal amount = command.bigDecimalValueOfParameterNamed(ClientApiConstants.amountParamName);
public static ClientCharge createNew(final Client client, final Charge charge, final BigDecimal amount, final JsonCommand command) {
final LocalDate dueDate = command.localDateValueOfParameterNamed(ClientApiConstants.dueAsOfDateParamName);
final boolean status = true;
// Derive from charge definition if not passed in as a parameter
amount = (amount == null) ? charge.getAmount() : amount;
return new ClientCharge(client, charge, amount, dueDate, status);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.holiday.domain.HolidayRepositoryWrapper;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrencyRepositoryWrapper;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper;
import org.apache.fineract.portfolio.charge.domain.Charge;
Expand Down Expand Up @@ -78,6 +81,7 @@ public class ClientChargeWritePlatformServiceImpl implements ClientChargeWritePl
private final ClientTransactionRepository clientTransactionRepository;
private final PaymentDetailWritePlatformService paymentDetailWritePlatformService;
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepositoryWrapper;

@Override
public CommandProcessingResult addCharge(Long clientId, JsonCommand command) {
Expand All @@ -95,7 +99,12 @@ public CommandProcessingResult addCharge(Long clientId, JsonCommand command) {
throw new ChargeCannotBeAppliedToException("client", errorMessage, charge.getId());
}

final ClientCharge clientCharge = ClientCharge.createNew(client, charge, command);
BigDecimal roundedAmount = calculateRoundedChargeAmount(charge, command);
if (roundedAmount.compareTo(BigDecimal.ZERO) == 0) {
return new CommandProcessingResultBuilder().withOfficeId(client.getOffice().getId()).withClientId(client.getId()).build();
}

final ClientCharge clientCharge = ClientCharge.createNew(client, charge, roundedAmount, command);
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat());
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
Expand Down Expand Up @@ -428,4 +437,16 @@ private void handleDataIntegrityIssues(@SuppressWarnings("unused") final Long cl
"Unknown data integrity issue with resource: " + realCause.getMessage());
}

private BigDecimal calculateRoundedChargeAmount(final Charge charge, final JsonCommand command) {
BigDecimal amount = command.bigDecimalValueOfParameterNamed(ClientApiConstants.amountParamName);
amount = (amount == null) ? charge.getAmount() : amount;

ApplicationCurrency currency = this.applicationCurrencyRepositoryWrapper.findOneWithNotFoundDetection(charge.getCurrencyCode());

CurrencyData currencyData = currency.toData();
BigDecimal rounded = Money.of(currencyData, amount).getAmount();

return rounded;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,38 @@ public CommandProcessingResult addLoanCharge(final Long loanId, final JsonComman
LoanTrancheDisbursementCharge loanTrancheDisbursementCharge;
ExternalId externalId = externalIdFactory.createFromCommand(command, "externalId");
boolean isFirst = true;
boolean atLeastOneChargePersisted = false;
boolean eligibleDisbursementFound = false;
for (LoanDisbursementDetails disbursementDetail : loanDisburseDetails) {
if (disbursementDetail.actualDisbursementDate() == null) {
eligibleDisbursementFound = true;
// If multiple charges to be applied, only the first one will get the provided externalId, for the
// rest we generate new ones (if needed)
if (!isFirst) {
externalId = externalIdFactory.create();
}
LocalDate dueDate = disbursementDetail.expectedDisbursementDateAsLocalDate();
loanCharge = loanChargeAssembler.createNewWithoutLoan(chargeDefinition, disbursementDetail.getPrincipal(), null, null,
null, dueDate, null, null, externalId);
loanTrancheDisbursementCharge = new LoanTrancheDisbursementCharge(loanCharge, disbursementDetail);
loanCharge.updateLoanTrancheDisbursementCharge(loanTrancheDisbursementCharge);
businessEventNotifierService.notifyPreBusinessEvent(new LoanAddChargeBusinessEvent(loanCharge));
validateAddLoanCharge(loan, chargeDefinition, loanCharge);
addCharge(loan, chargeDefinition, loanCharge);
LoanCharge tempCharge = loanChargeAssembler.createNewWithoutLoan(chargeDefinition, disbursementDetail.getPrincipal(),
null, null, null, dueDate, null, null, externalId);
loanTrancheDisbursementCharge = new LoanTrancheDisbursementCharge(tempCharge, disbursementDetail);
tempCharge.updateLoanTrancheDisbursementCharge(loanTrancheDisbursementCharge);
businessEventNotifierService.notifyPreBusinessEvent(new LoanAddChargeBusinessEvent(tempCharge));
validateAddLoanCharge(loan, chargeDefinition, tempCharge);
addCharge(loan, chargeDefinition, tempCharge);

// if charge amount is rounded to zero, then continue and look for another disbursement
if (isZeroCharge(tempCharge)) {
continue;
}

// tempCharge was persisted so this is true
atLeastOneChargePersisted = true;

// this means that loanCharge will have the first valid persisted tempCharge value
if (loanCharge == null) {
loanCharge = tempCharge;
}

isAppliedOnBackDate = true;
if (DateUtils.isAfter(recalculateFrom, dueDate)) {
recalculateFrom = dueDate;
Expand All @@ -231,18 +248,36 @@ public CommandProcessingResult addLoanCharge(final Long loanId, final JsonComman
isFirst = false;
}
}
if (loanCharge == null) {
// No eligible disbursement
if (!eligibleDisbursementFound) {
final String errorMessage = "Charge with identifier " + chargeDefinition.getId()
+ " cannot be applied: No valid loan disbursement available";
throw new ChargeCannotBeAppliedToException("loan", errorMessage, chargeDefinition.getId());
}

// Eligible disbursement exists but all zero
if (!atLeastOneChargePersisted) {
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(loan.getOfficeId())
.withClientId(loan.getClientId()).withGroupId(loan.getGroupId()).withLoanId(loanId).build();
}

// At least one valid persisted charge
loan.addTrancheLoanCharge(chargeDefinition);
} else {
loanCharge = loanChargeAssembler.createNewFromJson(loan, chargeDefinition, command);
businessEventNotifierService.notifyPreBusinessEvent(new LoanAddChargeBusinessEvent(loanCharge));

validateAddLoanCharge(loan, chargeDefinition, loanCharge);
isAppliedOnBackDate = addCharge(loan, chargeDefinition, loanCharge);

// if charge amount is rounded to zero, then return early
if (isZeroCharge(loanCharge)) {
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
.withGroupId(loan.getGroupId()) //
.withLoanId(loanId) //
.build();
}

if (DateUtils.isAfter(recalculateFrom, loanCharge.getDueLocalDate())) {
isAppliedOnBackDate = true;
recalculateFrom = loanCharge.getDueLocalDate();
Expand Down Expand Up @@ -1052,32 +1087,39 @@ private boolean addCharge(final Loan loan, final Charge chargeDefinition, LoanCh
loanChargeValidator.validateChargeAdditionForDisbursedLoan(loan, loanCharge);
loanChargeValidator.validateChargeHasValidSpecifiedDateIfApplicable(loan, loanCharge, loan.getDisbursementDate());
loanChargeService.addLoanCharge(loan, loanCharge);
loanCharge = this.loanChargeRepository.saveAndFlush(loanCharge);

// we want to apply charge transactions only for those loans charges that are applied when a loan is active and
// the loan product uses Upfront Accrual accounting (or None/Cash when allow-cash-and-non-cash-accrual is true),
// or only when the loan is closed too
final boolean accrualEnabledForActiveStatus = configurationDomainService.isAllowCashAndNonCashAccrual()
? loan.isNoneOrCashOrUpfrontAccrualAccountingEnabledOnLoanProduct()
: loan.isUpfrontAccrualAccountingEnabledOnLoanProduct();
if ((loan.getStatus().isActive() && accrualEnabledForActiveStatus) || loan.getStatus().isOverpaid()
|| loan.getStatus().isClosedObligationsMet()) {
final LoanTransaction applyLoanChargeTransaction = loanChargeService.handleChargeAppliedTransaction(loan, loanCharge, null);
if (applyLoanChargeTransaction != null) {
this.loanTransactionRepository.saveAndFlush(applyLoanChargeTransaction);
loanJournalEntryPoster.postJournalEntriesForLoanTransaction(applyLoanChargeTransaction, false, false);
businessEventNotifierService
.notifyPostBusinessEvent(new LoanAccrualTransactionCreatedBusinessEvent(applyLoanChargeTransaction));

// If charge amount is not zero
if (!isZeroCharge(loanCharge)) {
loanCharge = this.loanChargeRepository.saveAndFlush(loanCharge);

// we want to apply charge transactions only for those loans charges that are applied when a loan is active
// and
// the loan product uses Upfront Accrual accounting (or None/Cash when allow-cash-and-non-cash-accrual is
// true),
// or only when the loan is closed too
final boolean accrualEnabledForActiveStatus = configurationDomainService.isAllowCashAndNonCashAccrual()
? loan.isNoneOrCashOrUpfrontAccrualAccountingEnabledOnLoanProduct()
: loan.isUpfrontAccrualAccountingEnabledOnLoanProduct();
if ((loan.getStatus().isActive() && accrualEnabledForActiveStatus) || loan.getStatus().isOverpaid()
|| loan.getStatus().isClosedObligationsMet()) {
final LoanTransaction applyLoanChargeTransaction = loanChargeService.handleChargeAppliedTransaction(loan, loanCharge, null);
if (applyLoanChargeTransaction != null) {
this.loanTransactionRepository.saveAndFlush(applyLoanChargeTransaction);
loanJournalEntryPoster.postJournalEntriesForLoanTransaction(applyLoanChargeTransaction, false, false);
businessEventNotifierService
.notifyPostBusinessEvent(new LoanAccrualTransactionCreatedBusinessEvent(applyLoanChargeTransaction));
}
} else if (configurationDomainService.isImmediateChargeAccrualPostMaturityEnabled()
&& DateUtils.getBusinessLocalDate().isAfter(loan.getMaturityDate())) {
final LoanTransaction loanTransaction = loanChargeService.createChargeAppliedTransaction(loan, loanCharge, null);
this.loanTransactionRepository.saveAndFlush(loanTransaction);
loanJournalEntryPoster.postJournalEntriesForLoanTransaction(loanTransaction, false, false);
businessEventNotifierService.notifyPostBusinessEvent(new LoanAccrualTransactionCreatedBusinessEvent(loanTransaction));
}
} else if (configurationDomainService.isImmediateChargeAccrualPostMaturityEnabled()
&& DateUtils.getBusinessLocalDate().isAfter(loan.getMaturityDate())) {
final LoanTransaction loanTransaction = loanChargeService.createChargeAppliedTransaction(loan, loanCharge, null);
this.loanTransactionRepository.saveAndFlush(loanTransaction);
loanJournalEntryPoster.postJournalEntriesForLoanTransaction(loanTransaction, false, false);
businessEventNotifierService.notifyPostBusinessEvent(new LoanAccrualTransactionCreatedBusinessEvent(loanTransaction));
}

return DateUtils.isBeforeBusinessDate(loanCharge.getDueLocalDate());
return DateUtils.isBeforeBusinessDate(loanCharge.getDueLocalDate());
}
return false;
}

private LoanOverdueDTO applyChargeToOverdueLoanInstallment(final Loan loan, final Long loanChargeId, final Integer periodNumber,
Expand Down Expand Up @@ -1453,4 +1495,9 @@ public LoanTransaction waiveLoanCharge(final Loan loan, final LoanCharge loanCha

return waiveLoanChargeTransaction;
}

private boolean isZeroCharge(LoanCharge loanCharge) {
return loanCharge.getAmount() != null && loanCharge.getAmount().compareTo(BigDecimal.ZERO) == 0;
}

}
Loading