diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java index 7f5a52723d5..6a523f4611c 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java @@ -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() { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeService.java index 3e1d256cad3..bc97110df24 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeService.java @@ -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) { @@ -384,6 +389,7 @@ public Map 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; @@ -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: @@ -998,4 +1004,8 @@ private boolean doesLoanChargePaidByContainLoanCharge(Set loan .anyMatch(loanChargePaidBy -> loanChargePaidBy.getLoanCharge().equals(loanCharge)); } + private boolean isZeroCharge(LoanCharge loanCharge) { + return loanCharge.getAmount() != null && loanCharge.getAmount().compareTo(BigDecimal.ZERO) == 0; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/api/AccountsApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/api/AccountsApiResourceSwagger.java index 049f2191059..9cf6203eaad 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/api/AccountsApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/api/AccountsApiResourceSwagger.java @@ -465,6 +465,19 @@ 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() {} @@ -472,8 +485,6 @@ private PostAccountsRequestedShares() {} @Schema(example = "35") public Long id; } - - public Set requestedShares; } @Schema(description = "PostAccountsTypeAccountIdResponse") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientChargesApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientChargesApiResourceSwagger.java index a2d8d57c6e4..bdf6e0900c3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientChargesApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientChargesApiResourceSwagger.java @@ -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") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java index 237e094fcbe..67dacd6290a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChargeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChargeWritePlatformServiceImpl.java index 2327184c4cf..bacfd23bd95 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChargeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChargeWritePlatformServiceImpl.java @@ -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; @@ -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) { @@ -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 dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) @@ -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; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java index d9523f2d00e..f2007fe5eaf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java @@ -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; @@ -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(); @@ -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, @@ -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; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index a7daaf22767..8ed4fd934e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -75,6 +75,7 @@ import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.holiday.domain.HolidayRepositoryWrapper; +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.organisation.office.domain.Office; @@ -88,6 +89,7 @@ import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType; import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; import org.apache.fineract.portfolio.client.domain.Client; @@ -1236,6 +1238,11 @@ public CommandProcessingResult addSavingsAccountCharge(final JsonCommand command } final SavingsAccountCharge savingsAccountCharge = SavingsAccountCharge.createNewFromJson(savingsAccount, chargeDefinition, command); + if (isFlatCharge(savingsAccountCharge) && isZeroCharge(savingsAccountCharge, savingsAccount.getCurrency())) { + return new CommandProcessingResultBuilder().withOfficeId(savingsAccount.officeId()).withClientId(savingsAccount.clientId()) + .withGroupId(savingsAccount.groupId()).withSavingsId(savingsAccountId).build(); + } + if (chargeDefinition.isEnableFreeWithdrawal()) { savingsAccountCharge.setFreeWithdrawalCount(0); } @@ -2086,4 +2093,12 @@ private void validateReasonForHold(String reasonForBlock) { throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory"); } } + + private boolean isZeroCharge(SavingsAccountCharge charge, MonetaryCurrency currency) { + return charge.getAmount(currency).isZero(); + } + + private boolean isFlatCharge(SavingsAccountCharge savingsAccountCharge) { + return ChargeCalculationType.fromInt(savingsAccountCharge.getCharge().getChargeCalculation()).isFlat(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java index 79953b34ddc..1b7da932987 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java @@ -374,7 +374,7 @@ public Integer getChargeTimeType() { } public BigDecimal deriveChargeAmount(BigDecimal transactionAmount, final MonetaryCurrency currency) { - BigDecimal toReturnAmount = amountOrPercentage; + BigDecimal toReturnAmount; if (ChargeCalculationType.fromInt(this.chargeCalculation) == ChargeCalculationType.PERCENT_OF_AMOUNT) { toReturnAmount = Money.of(currency, percentageOf(transactionAmount, this.percentage)).getAmount(); this.amountPercentageAppliedTo = transactionAmount; @@ -384,7 +384,8 @@ public BigDecimal deriveChargeAmount(BigDecimal transactionAmount, final Monetar this.amountWaived = null; this.amountWrittenOff = null; } else { - this.amount = this.amountOrPercentage; + this.amount = Money.of(currency, this.amountOrPercentage).getAmount(); + toReturnAmount = this.amount; this.amountOutstanding = calculateOutstanding(); this.amountWaived = null; this.amountWrittenOff = null; @@ -393,7 +394,7 @@ public BigDecimal deriveChargeAmount(BigDecimal transactionAmount, final Monetar } public BigDecimal updateChargeDetailsForAdditionalSharesRequest(final BigDecimal transactionAmount, final MonetaryCurrency currency) { - BigDecimal toReturnAmount = amountOrPercentage; + BigDecimal toReturnAmount; if (ChargeCalculationType.fromInt(this.chargeCalculation) == ChargeCalculationType.PERCENT_OF_AMOUNT) { toReturnAmount = Money.of(currency, percentageOf(transactionAmount, this.percentage)).getAmount(); this.amountPercentageAppliedTo = this.amountPercentageAppliedTo.add(transactionAmount); @@ -402,7 +403,9 @@ public BigDecimal updateChargeDetailsForAdditionalSharesRequest(final BigDecimal this.amountWaived = null; this.amountWrittenOff = null; } else { - this.amount = this.amount.add(this.amountOrPercentage); + BigDecimal roundedAmount = Money.of(currency, this.amountOrPercentage).getAmount(); + toReturnAmount = roundedAmount; + this.amount = this.amount.add(roundedAmount); this.amountOutstanding = calculateOutstanding(); this.amountWaived = null; this.amountWrittenOff = null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java index af21e9cab93..8b9be7f5cfa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java @@ -114,7 +114,7 @@ public static ShareAccountTransaction createChargeTransaction(final LocalDate tr final BigDecimal unitPrice = null; final Integer status = PurchasedSharesStatusType.APPROVED.getValue(); final Integer type = PurchasedSharesStatusType.CHARGE_PAYMENT.getValue(); - BigDecimal amount = charge.percentageOrAmount(); + BigDecimal amount = charge.amoutOutstanding(); BigDecimal chargeAmount = null; BigDecimal amountPaid = null; return new ShareAccountTransaction(transactionDate, totalShares, unitPrice, status, type, amount, chargeAmount, amountPaid); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 622e6b67c51..0dc62114861 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -32,6 +32,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -240,11 +241,18 @@ private void createChargeTransaction(ShareAccount account) { BigDecimal totalChargeAmount = BigDecimal.ZERO; Set charges = account.getCharges(); LocalDate currentDate = DateUtils.getBusinessLocalDate(); - for (ShareAccountCharge charge : charges) { + + Iterator activationIterator = charges.iterator(); + while (activationIterator.hasNext()) { + ShareAccountCharge charge = activationIterator.next(); if (charge.isActive() && charge.isShareAccountActivation()) { - charge.deriveChargeAmount(totalChargeAmount, account.getCurrency()); + BigDecimal amount = charge.deriveChargeAmount(totalChargeAmount, account.getCurrency()); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + activationIterator.remove(); + continue; + } ShareAccountTransaction chargeTransaction = ShareAccountTransaction.createChargeTransaction(currentDate, charge); - ShareAccountChargePaidBy paidBy = new ShareAccountChargePaidBy(chargeTransaction, charge, charge.percentageOrAmount()); + ShareAccountChargePaidBy paidBy = new ShareAccountChargePaidBy(chargeTransaction, charge, amount); chargeTransaction.addShareAccountChargePaidBy(paidBy); account.addChargeTransaction(chargeTransaction); } @@ -252,9 +260,15 @@ private void createChargeTransaction(ShareAccount account) { Set pendingApprovalTransaction = account.getPendingForApprovalSharePurchaseTransactions(); for (ShareAccountTransaction pending : pendingApprovalTransaction) { - for (ShareAccountCharge charge : charges) { + Iterator purchaseIterator = charges.iterator(); + while (purchaseIterator.hasNext()) { + ShareAccountCharge charge = purchaseIterator.next(); if (charge.isActive() && charge.isSharesPurchaseCharge()) { BigDecimal amount = charge.deriveChargeAmount(pending.amount(), account.getCurrency()); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + purchaseIterator.remove(); + continue; + } ShareAccountChargePaidBy paidBy = new ShareAccountChargePaidBy(pending, charge, amount); pending.addShareAccountChargePaidBy(paidBy); totalChargeAmount = totalChargeAmount.add(amount); @@ -984,9 +998,15 @@ private LocalDate deriveLockinPeriodDuration(final Integer lockinPeriod, final P private void handleRedeemSharesChargeTransactions(final ShareAccount account, final ShareAccountTransaction transaction) { Set charges = account.getCharges(); BigDecimal totalChargeAmount = BigDecimal.ZERO; - for (ShareAccountCharge charge : charges) { + Iterator iterator = charges.iterator(); + while (iterator.hasNext()) { + ShareAccountCharge charge = iterator.next(); if (charge.isActive() && charge.isSharesRedeemCharge()) { BigDecimal amount = charge.updateChargeDetailsForAdditionalSharesRequest(transaction.amount(), account.getCurrency()); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + iterator.remove(); + continue; + } ShareAccountChargePaidBy paidBy = new ShareAccountChargePaidBy(transaction, charge, amount); transaction.addShareAccountChargePaidBy(paidBy); totalChargeAmount = totalChargeAmount.add(amount); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountCharge.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountCharge.java index 93127dfed9e..03fe4c28098 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountCharge.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountCharge.java @@ -263,11 +263,12 @@ private void populateDerivedFields(final BigDecimal transactionAmount, final Big this.amountWrittenOff = null; break; case FLAT: + Money money = Money.of(this.savingsAccount().getCurrency(), chargeAmount); this.percentage = null; - this.amount = chargeAmount; + this.amount = money.getAmount(); this.amountPercentageAppliedTo = null; this.amountPaid = null; - this.amountOutstanding = chargeAmount; + this.amountOutstanding = money.getAmount(); this.amountWaived = null; this.amountWrittenOff = null; break; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientChargeRoundingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientChargeRoundingTest.java new file mode 100644 index 00000000000..d1a5aa873a9 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientChargeRoundingTest.java @@ -0,0 +1,165 @@ +/** + * 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.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.CurrencyConfigurationData; +import org.apache.fineract.client.models.CurrencyUpdateRequest; +import org.apache.fineract.client.models.GetClientsChargesPageItems; +import org.apache.fineract.client.models.GetClientsClientIdChargesResponse; +import org.apache.fineract.client.models.PostChargesResponse; +import org.apache.fineract.client.models.PostClientsClientIdChargesRequest; +import org.apache.fineract.client.models.PostClientsClientIdChargesResponse; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.FineractClientHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@Slf4j +public class ClientChargeRoundingTest { + + private Long clientId; + private ChargesHelper chargesHelper; + private static final String DATE = "01 January 2026"; + + @BeforeEach + public void setup() throws Exception { + enableRequiredCurrencies(); + clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + chargesHelper = new ChargesHelper(); + } + + @Test + public void shouldRoundUsdClientChargeTo_TwoDecimalPlaces() throws Exception { + PostChargesResponse chargesResponse = createFlatClientCharge(19.876, "USD"); + + PostClientsClientIdChargesResponse appliedCharge = applyChargeToClient(clientId, chargesResponse.getResourceId(), + new BigDecimal("19.876")); + + GetClientsChargesPageItems charge = getClientCharge(clientId, appliedCharge.getResourceId()); + + assertNotNull(charge); + BigDecimal actualChargeAmount = charge.getAmount(); + BigDecimal expectedChargeAmount = new BigDecimal("19.88"); + assertAmountEquals(expectedChargeAmount, actualChargeAmount); + } + + @Test + public void shouldRoundJpyClientChargeTo_ZeroDecimalPlaces() throws Exception { + PostChargesResponse chargesResponse = createFlatClientCharge(19.8, "JPY"); + + PostClientsClientIdChargesResponse appliedCharge = applyChargeToClient(clientId, chargesResponse.getResourceId(), + new BigDecimal("19.8")); + + GetClientsChargesPageItems charge = getClientCharge(clientId, appliedCharge.getResourceId()); + + assertNotNull(charge); + BigDecimal actualChargeAmount = charge.getAmount(); + BigDecimal expectedChargeAmount = new BigDecimal("20"); + assertAmountEquals(expectedChargeAmount, actualChargeAmount); + } + + @Test + public void shouldRoundUpJpyClientCharge_whenValueIsAboveHalfTo_ZeroDecimalPlaces() throws Exception { + PostChargesResponse chargesResponse = createFlatClientCharge(0.55, "JPY"); + + PostClientsClientIdChargesResponse appliedCharge = applyChargeToClient(clientId, chargesResponse.getResourceId(), + new BigDecimal("0.55")); + + GetClientsChargesPageItems charge = getClientCharge(clientId, appliedCharge.getResourceId()); + + assertNotNull(charge); + BigDecimal actualChargeAmount = charge.getAmount(); + BigDecimal expectedChargeAmount = new BigDecimal("1"); + assertAmountEquals(expectedChargeAmount, actualChargeAmount); + } + + @Test + public void shouldNotPersistJpyClientCharge_whenRoundedToZero() throws Exception { + PostChargesResponse chargesResponse = createFlatClientCharge(0.5, "JPY"); + + applyChargeToClient(clientId, chargesResponse.getResourceId(), new BigDecimal("0.5")); + + assertFalse(hasAnyClientCharges(clientId), "Expected no client charge to be created when rounded amount becomes 0"); + } + + // ----------------------------- + // HELPERS + // ----------------------------- + + private PostChargesResponse createFlatClientCharge(double amount, String currencyCode) { + return chargesHelper.createCharges(new ChargeRequest().name("Client Charge Flat " + UUID.randomUUID()).chargeAppliesTo(3) // CLIENT + .chargeTimeType(2).chargeCalculationType(1) // FLAT + .amount(amount).currencyCode(currencyCode).locale("en").active(true).penalty(false)); + } + + private PostClientsClientIdChargesResponse applyChargeToClient(Long clientId, Long chargeId, BigDecimal amount) throws Exception { + return FineractClientHelper.getFineractClient().clientCharges.createClientCharge(clientId, new PostClientsClientIdChargesRequest() + .chargeId(chargeId).dateFormat("dd MMMM yyyy").amount(amount).locale("en").dueDate(DATE)).execute().body(); + } + + private GetClientsChargesPageItems getClientCharge(Long clientId, Long chargeId) throws IOException { + return FineractClientHelper.getFineractClient().clientCharges.retrieveOneClientCharge(clientId, chargeId).execute().body(); + } + + private boolean hasAnyClientCharges(Long clientId) throws IOException { + + GetClientsClientIdChargesResponse response = FineractClientHelper.getFineractClient().clientCharges + .retrieveAllClientCharges(clientId, "all", null, null, null).execute().body(); + + return response != null && response.getPageItems() != null && !response.getPageItems().isEmpty(); + } + + private void assertAmountEquals(BigDecimal expected, BigDecimal actual) { + assertEquals(0, expected.compareTo(actual)); + } + + /** + * Currencies used by these tests must be explicitly enabled in the currency configuration. Otherwise, charge + * creation and persistence may succeed while retrieval APIs do not return the charges. + */ + private void enableRequiredCurrencies() throws Exception { + CurrencyUpdateRequest request = new CurrencyUpdateRequest().currencies(List.of("USD", "JPY")); + + FineractClientHelper.getFineractClient().currencies.updateCurrencies(request).execute(); + + CurrencyConfigurationData data = FineractClientHelper.getFineractClient().currencies.retrieveCurrencies().execute().body(); + + assertNotNull(data); + assertNotNull(data.getSelectedCurrencyOptions()); + assertCurrencyEnabled(data, "USD"); + assertCurrencyEnabled(data, "JPY"); + } + + private void assertCurrencyEnabled(CurrencyConfigurationData data, String currencyCode) { + assertTrue(data.getSelectedCurrencyOptions().stream().anyMatch(c -> currencyCode.equals(c.getCode())), + "Currency " + currencyCode + " should be enabled"); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeRoundingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeRoundingTest.java new file mode 100644 index 00000000000..a203293db1f --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeRoundingTest.java @@ -0,0 +1,785 @@ +/** + * 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.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReference; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.GetLoansLoanIdLoanChargeData; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PostChargesResponse; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansDisbursementData; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@Slf4j +public class LoanChargeRoundingTest extends BaseLoanIntegrationTest { + + private Long clientId; + + private static final String DATE = "01 January 2026"; + private static final String LATER_DATE = "01 June 2026"; + + @BeforeEach + public void setup() { + clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + } + + /** FLAT CHARGE **/ + @Test + public void shouldApplyRoundingRules_forFlatCharge() { + runAt(DATE, () -> { + + Long productId = createLoanProduct(0, 1); + + Long loanId = applyAndApproveLoan(productId, 10000.0, 1); + + PostChargesResponse chargeResponse = createFlatCharge(19.8); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 19.8); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("19.8"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpFlatCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + + Long productId = createLoanProduct(0, 1); + + Long loanId = applyAndApproveLoan(productId, 10000.0, 1); + + PostChargesResponse chargeResponse = createFlatCharge(0.6); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.6); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.6"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistFlatCharge_whenRoundedToZero() { + runAt(DATE, () -> { + + Long productId = createLoanProduct(0, 1); + + Long loanId = applyAndApproveLoan(productId, 10000.0, 1); + + PostChargesResponse chargeResponse = createFlatCharge(0.5); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.5); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.5"), 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoChargesPersisted(loanId, chargeResponse.getResourceId()); + }); + } + + /** PERCENTAGE OF AMOUNT CHARGE **/ + @Test + public void shouldApplyRoundingRules_forPercentageOfAmountCharge() { + runAt(DATE, () -> { + + Long productId = createLoanProduct(0, 3); + + Long loanId = applyAndApproveLoan(productId, 10000.0, 1); + + PostChargesResponse chargeResponse = createPercentageOfAmountCharge(2.5067); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 2.5067); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal principal = getLoanPrincipal(loanId); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(principal.toPlainString(), "0.025067", 0, 3); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + + }); + } + + @Test + public void shouldRoundUpPercentageOfAmountCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + + Long productId = createLoanProduct(0, 1); + + Long loanId = applyAndApproveLoan(productId, 10000.0, 1); + + PostChargesResponse chargeResponse = createPercentageOfAmountCharge(0.006); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.006); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal principal = getLoanPrincipal(loanId); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(principal.toPlainString(), "0.00006", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistPercentageOfAmountCharge_whenRoundedToZero() { + runAt(DATE, () -> { + Long productId = createLoanProduct(0, 1); + + Long loanId = applyAndApproveLoan(productId, 10000.0, 1); + + PostChargesResponse chargeResponse = createPercentageOfAmountCharge(0.005); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.005); + + BigDecimal principal = getLoanPrincipal(loanId); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(principal.toPlainString(), "0.00005", 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoChargesPersisted(loanId, chargeResponse.getResourceId()); + }); + } + + /** PERCENTAGE OF AMOUNT + INTEREST CHARGE **/ + @Test + public void shouldApplyRoundingRules_forPercentageOfAmountPlusInterestCharge_beforeInterestAccrual() { + runAt(DATE, () -> { + + Long productId = createLoanProduct(0, 1); + + Long loanId = applyAndApproveLoan(productId, 10000.0, 1); + + PostChargesResponse chargeResponse = createPercentageOfAmountPlusInterestCharge(2.536); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 2.536); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal principal = getLoanPrincipal(loanId); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(principal.toPlainString(), "0.02536", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldApplyRoundingRules_forPercentageOfAmountPlusInterestCharge_afterInterestAccrualViaCOB() { + AtomicReference loanIdRef = new AtomicReference<>(); + + runAt(DATE, () -> { + loanIdRef.set(createAndDisburseProgressiveLoan(10000.0, 0, 1)); + }); + + runAt(LATER_DATE, () -> { + + Long loanId = loanIdRef.get(); + + BigDecimal totalInterest = executeCobAndGetTotalInterest(loanId); + + PostChargesResponse chargeResponse = createPercentageOfAmountPlusInterestCharge(2.536); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), LATER_DATE, 2.536); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal base = new BigDecimal("10000").add(totalInterest); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(base.toPlainString(), "0.02536", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpPercentageOfAmountPlusInterestCharge_whenValueIsAboveHalf() { + AtomicReference loanIdRef = new AtomicReference<>(); + + runAt(DATE, () -> { + loanIdRef.set(createAndDisburseProgressiveLoan(100.0, 0, 1)); + }); + + runAt(LATER_DATE, () -> { + + Long loanId = loanIdRef.get(); + + BigDecimal totalInterest = executeCobAndGetTotalInterest(loanId); + + PostChargesResponse chargeResponse = createPercentageOfAmountPlusInterestCharge(0.56); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), LATER_DATE, 0.56); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal base = new BigDecimal("100").add(totalInterest); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(base.toPlainString(), "0.0056", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistPercentageOfAmountPlusInterestCharge_whenRoundedToZero() { + AtomicReference loanIdRef = new AtomicReference<>(); + + runAt(DATE, () -> { + loanIdRef.set(createAndDisburseProgressiveLoan(100.0, 0, 1)); + }); + + runAt(LATER_DATE, () -> { + + Long loanId = loanIdRef.get(); + + BigDecimal totalInterest = executeCobAndGetTotalInterest(loanId); + + PostChargesResponse chargeResponse = createPercentageOfAmountPlusInterestCharge(0.38); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), LATER_DATE, 0.38); + + BigDecimal base = new BigDecimal("100").add(totalInterest); + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(base.toPlainString(), "0.0038", 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoChargesPersisted(loanId, chargeResponse.getResourceId()); + }); + } + + /** PERCENTAGE OF INTEREST **/ + @Test + public void shouldApplyRoundingRules_forPercentageOfInterestCharge() { + AtomicReference loanIdRef = new AtomicReference<>(); + + runAt(DATE, () -> { + loanIdRef.set(createAndDisburseProgressiveLoan(10000.0, 0, 1)); + }); + + runAt(LATER_DATE, () -> { + + Long loanId = loanIdRef.get(); + + BigDecimal totalInterest = executeCobAndGetTotalInterest(loanId); + + PostChargesResponse chargeResponse = createPercentageOfInterestCharge(2.536); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), LATER_DATE, 2.536); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(totalInterest.toPlainString(), "0.02536", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpPercentageOfInterestCharge_whenValueIsAboveHalf() { + AtomicReference loanIdRef = new AtomicReference<>(); + + runAt(DATE, () -> { + loanIdRef.set(createAndDisburseProgressiveLoan(100.0, 0, 1)); + }); + + runAt(LATER_DATE, () -> { + + Long loanId = loanIdRef.get(); + + BigDecimal totalInterest = executeCobAndGetTotalInterest(loanId); + + PostChargesResponse chargeResponse = createPercentageOfInterestCharge(2.068); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), LATER_DATE, 2.068); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(totalInterest.toPlainString(), "0.02068", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistPercentageOfInterestCharge_whenRoundedToZero() { + AtomicReference loanIdRef = new AtomicReference<>(); + + runAt(DATE, () -> { + loanIdRef.set(createAndDisburseProgressiveLoan(100.0, 0, 1)); + }); + + runAt(LATER_DATE, () -> { + + Long loanId = loanIdRef.get(); + + BigDecimal totalInterest = executeCobAndGetTotalInterest(loanId); + + PostChargesResponse chargeResponse = createPercentageOfInterestCharge(1.68); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), LATER_DATE, 1.68); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge(totalInterest.toPlainString(), "0.0168", 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoChargesPersisted(loanId, chargeResponse.getResourceId()); + }); + } + + /** PERCENTAGE OF TRANCHE DISBURSEMENT */ + @Test + public void shouldApplyRoundingRules_forPercentageOfTrancheDisbursementCharge() { + runAt(DATE, () -> { + + Long productId = createMultiDisbursementLoanProduct(0, 1); + + List disbursements = List.of( + new PostLoansDisbursementData().expectedDisbursementDate("01 January 2026").principal(new BigDecimal("615")), + new PostLoansDisbursementData().expectedDisbursementDate("01 February 2026").principal(new BigDecimal("385"))); + + Long loanId = applyAndApproveMultiTrancheLoan(productId, disbursements); + + PostChargesResponse chargeResponse = createPercentageOfTrancheDisbursementCharge(0.5); + assertNotNull(chargeResponse.getResourceId()); + + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.5); + + List charges = getLoanCharges(loanId); + assertNotNull(charges); + assertEquals(2, charges.size()); + + List actualAmounts = extractAndSortAmounts(charges); + assertTrue(actualAmounts.stream().allMatch(amount -> amount.compareTo(BigDecimal.ZERO) > 0)); + + List expectedAmounts = calculateExpectedTrancheCharges(disbursements, "0.005", 0, 1); + + assertFalse(expectedAmounts.isEmpty()); + assertEquals(expectedAmounts.size(), actualAmounts.size()); + assertBigDecimalListEquals(expectedAmounts, actualAmounts); + }); + } + + @Test + public void shouldPersistOnlyNonZeroRoundedCharges_forPercentageOfTrancheDisbursementCharge() { + runAt(DATE, () -> { + + Long productId = createMultiDisbursementLoanProduct(0, 1); + + List disbursements = List.of( + new PostLoansDisbursementData().expectedDisbursementDate("01 January 2026").principal(new BigDecimal("615")), + new PostLoansDisbursementData().expectedDisbursementDate("01 February 2026").principal(new BigDecimal("385"))); + + Long loanId = applyAndApproveMultiTrancheLoan(productId, disbursements); + + PostChargesResponse chargeResponse = createPercentageOfTrancheDisbursementCharge(0.09); + assertNotNull(chargeResponse.getResourceId()); + + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.09); + + List charges = getLoanCharges(loanId); + assertNotNull(charges); + assertEquals(1, charges.size()); + + List actualAmounts = extractAndSortAmounts(charges); + assertTrue(actualAmounts.stream().allMatch(amount -> amount.compareTo(BigDecimal.ZERO) > 0)); + + List expectedAmounts = calculateExpectedTrancheCharges(disbursements, "0.0009", 0, 1); + + assertFalse(expectedAmounts.isEmpty()); + assertEquals(expectedAmounts.size(), actualAmounts.size()); + assertBigDecimalListEquals(expectedAmounts, actualAmounts); + }); + } + + @Test + public void shouldNotPersistAnyCharges_whenAllRoundedChargesBecomeZero_forPercentageOfTrancheDisbursementCharge() { + runAt(DATE, () -> { + + Long productId = createMultiDisbursementLoanProduct(0, 1); + + List disbursements = List.of( + new PostLoansDisbursementData().expectedDisbursementDate("01 January 2026").principal(new BigDecimal("615")), + new PostLoansDisbursementData().expectedDisbursementDate("01 February 2026").principal(new BigDecimal("385"))); + + Long loanId = applyAndApproveMultiTrancheLoan(productId, disbursements); + + PostChargesResponse chargeResponse = createPercentageOfTrancheDisbursementCharge(0.04); + assertNotNull(chargeResponse.getResourceId()); + + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.04); + + List charges = getLoanCharges(loanId); + assertTrue(charges == null || charges.isEmpty(), "Expected no charges since rounded amount becomes 0"); + + List expectedAmounts = calculateExpectedTrancheCharges(disbursements, "0.0004", 0, 1); + assertTrue(expectedAmounts.isEmpty()); + }); + } + + /** PERCENTAGE OF DISBURSEMENT */ + @Test + public void shouldApplyRoundingRules_forPercentageOfDisbursementCharge() { + runAt(DATE, () -> { + + Long productId = createMultiDisbursementLoanProduct(0, 1); + + List disbursements = List.of( + new PostLoansDisbursementData().expectedDisbursementDate("01 January 2026").principal(new BigDecimal("615")), + new PostLoansDisbursementData().expectedDisbursementDate("01 February 2026").principal(new BigDecimal("385"))); + + Long loanId = applyAndApproveMultiTrancheLoan(productId, disbursements); + + PostChargesResponse chargeResponse = createPercentageOfDisbursementCharge(0.5); + + assertNotNull(chargeResponse.getResourceId()); + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.5); + + List charges = getLoanCharges(loanId); + + assertNotNull(charges); + assertEquals(1, charges.size()); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("615", "0.005", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpPercentageOfDisbursementCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + + Long productId = createMultiDisbursementLoanProduct(0, 1); + + List disbursements = List.of( + new PostLoansDisbursementData().expectedDisbursementDate("01 January 2026").principal(new BigDecimal("615")), + new PostLoansDisbursementData().expectedDisbursementDate("01 February 2026").principal(new BigDecimal("385"))); + + Long loanId = applyAndApproveMultiTrancheLoan(productId, disbursements); + + PostChargesResponse chargeResponse = createPercentageOfDisbursementCharge(0.09); + assertNotNull(chargeResponse.getResourceId()); + + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.09); + + List charges = getLoanCharges(loanId); + + assertNotNull(charges); + assertEquals(1, charges.size()); + + BigDecimal actualChargeAmount = getLoanChargeAmount(loanId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("615", "0.0009", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistPercentageOfDisbursementCharge_whenRoundedToZero() { + runAt(DATE, () -> { + + Long productId = createMultiDisbursementLoanProduct(0, 1); + + List disbursements = List.of( + new PostLoansDisbursementData().expectedDisbursementDate("01 January 2026").principal(new BigDecimal("615")), + new PostLoansDisbursementData().expectedDisbursementDate("01 February 2026").principal(new BigDecimal("385"))); + + Long loanId = applyAndApproveMultiTrancheLoan(productId, disbursements); + + PostChargesResponse chargeResponse = createPercentageOfDisbursementCharge(0.04); + assertNotNull(chargeResponse.getResourceId()); + + addLoanCharge(loanId, chargeResponse.getResourceId(), DATE, 0.04); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("615", "0.0004", 0, 1); + + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoChargesPersisted(loanId, chargeResponse.getResourceId()); + }); + } + + // ----------------------------- + // HELPERS + // ----------------------------- + + private Long createLoanProduct(int digitsAfterDecimal, int inMultiplesOf) { + return loanProductHelper + .createLoanProduct(baseLoanProductRequest().digitsAfterDecimal(digitsAfterDecimal).inMultiplesOf(inMultiplesOf)) + .getResourceId(); + } + + private Long createMultiDisbursementLoanProduct(int digitsAfterDecimal, int inMultiplesOf) { + return loanProductHelper + .createLoanProduct( + baseLoanProductRequestMultiRepayment().digitsAfterDecimal(digitsAfterDecimal).inMultiplesOf(inMultiplesOf)) + .getResourceId(); + } + + private PostLoanProductsRequest baseLoanProductRequest() { + return new PostLoanProductsRequest().name("LP-" + UUID.randomUUID()).shortName(randomShortName()).currencyCode("USD").locale("en") + .dateFormat("dd MMMM yyyy").principal(10000.00).numberOfRepayments(1).repaymentEvery(1).repaymentFrequencyType(1L) + .interestRatePerPeriod(1.0).interestRateFrequencyType(1).amortizationType(1).interestType(0) + .interestCalculationPeriodType(1).transactionProcessingStrategyCode("mifos-standard-strategy").accountingRule(1) + .isInterestRecalculationEnabled(false).daysInYearType(1).daysInMonthType(1); + } + + private String randomShortName() { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + return "L" + chars.charAt(ThreadLocalRandom.current().nextInt(chars.length())) + + chars.charAt(ThreadLocalRandom.current().nextInt(chars.length())) + + chars.charAt(ThreadLocalRandom.current().nextInt(chars.length())); + } + + private PostLoanProductsRequest baseLoanProductRequestMultiRepayment() { + return baseLoanProductRequest().interestCalculationPeriodType(0).multiDisburseLoan(true).maxTrancheCount(2) + .outstandingLoanBalance(0.0).disallowExpectedDisbursements(false); + } + + private Long applyAndApproveLoan(Long productId, double principal, int repayments) { + PostLoansResponse response = loanTransactionHelper.applyLoan(applyLoanRequest(clientId, productId, DATE, principal, repayments)); + Long loanId = response.getLoanId(); + BigDecimal approvedPrincipal = getLoanPrincipal(loanId); + assertNotNull(approvedPrincipal); + loanTransactionHelper.approveLoan(loanId, approveLoanRequest(approvedPrincipal.doubleValue(), DATE)); + return loanId; + } + + private BigDecimal getLoanPrincipal(Long loanId) { + return loanTransactionHelper.getLoanDetails(loanId).getPrincipal(); + } + + private Long applyAndApproveMultiTrancheLoan(Long productId, List disbursements) { + double totalPrincipal = disbursements.stream().map(PostLoansDisbursementData::getPrincipal).mapToDouble(BigDecimal::doubleValue) + .sum(); + + PostLoansRequest request = applyLoanRequest(clientId, productId, DATE, totalPrincipal, disbursements.size()); + request.interestCalculationPeriodType(0).setDisbursementData(disbursements); + PostLoansResponse response = loanTransactionHelper.applyLoan(request); + Long loanId = response.getLoanId(); + loanTransactionHelper.approveLoan(loanId, approveLoanRequest(totalPrincipal, DATE)); + return loanId; + } + + private PostChargesResponse createFlatCharge(double amount) { + return chargesHelper.createCharges( + new ChargeRequest().name("Flat Charge" + UUID.randomUUID()).chargeAppliesTo(1).chargeTimeType(2).chargeCalculationType(1) // Flat + .amount(amount).currencyCode("USD").locale("en").chargePaymentMode(0).active(true).penalty(false)); + } + + private PostChargesResponse createPercentageOfAmountCharge(double percentage) { + return chargesHelper.createCharges(new ChargeRequest().name("Percentage of Amount Charge" + UUID.randomUUID()).chargeAppliesTo(1) + .chargeTimeType(2).chargeCalculationType(2) // % of amount + .amount(percentage).currencyCode("USD").locale("en").chargePaymentMode(0).active(true).penalty(false)); + } + + private PostChargesResponse createPercentageOfAmountPlusInterestCharge(double percentage) { + return chargesHelper.createCharges(new ChargeRequest().name("Percentage of Amount + Interest Charge" + UUID.randomUUID()) + .chargeAppliesTo(1).chargeTimeType(2).chargeCalculationType(3) // % of (amount + interest) + .amount(percentage).currencyCode("USD").locale("en").chargePaymentMode(0).active(true).penalty(false)); + } + + private PostChargesResponse createPercentageOfInterestCharge(double percentage) { + return chargesHelper.createCharges(new ChargeRequest().name("Percentage of Amount + Interest Charge" + UUID.randomUUID()) + .chargeAppliesTo(1).chargeTimeType(2).chargeCalculationType(4) // % of (interest) + .amount(percentage).currencyCode("USD").locale("en").chargePaymentMode(0).active(true).penalty(false)); + } + + private PostChargesResponse createPercentageOfTrancheDisbursementCharge(double percentage) { + return chargesHelper.createCharges(new ChargeRequest().name("Tranche Charge" + UUID.randomUUID()).chargeAppliesTo(1) + .chargeTimeType(12).chargeCalculationType(5) // % of disbursement amount + .amount(percentage).currencyCode("USD").locale("en").chargePaymentMode(0).active(true).penalty(false)); + } + + private PostChargesResponse createPercentageOfDisbursementCharge(double percentage) { + return chargesHelper + .createCharges(new ChargeRequest().name("Disbursement Charge" + UUID.randomUUID()).chargeAppliesTo(1).chargeTimeType(1) // Disbursement + .chargeCalculationType(2) // % of amount + .amount(percentage).currencyCode("USD").locale("en").chargePaymentMode(0).active(true).penalty(false)); + } + + private BigDecimal getLoanChargeAmount(Long loanId, Long chargeId) { + List charges = getLoanCharges(loanId); + assertNotNull(charges); + GetLoansLoanIdLoanChargeData charge = charges.stream().filter(c -> Objects.equals(c.getChargeId(), chargeId)).findFirst() + .orElseThrow(() -> new AssertionError("Loan charge not found: " + chargeId)); + + BigDecimal amount = charge.getAmount(); + assertNotNull(amount); + return amount; + } + + private BigDecimal calculateExpectedPercentageCharge(String baseAmount, String percentageAsDecimal, int digitsAfterDecimal, + int inMultiplesOf) { + BigDecimal base = new BigDecimal(baseAmount); + BigDecimal percentage = new BigDecimal(percentageAsDecimal); + BigDecimal rawCharge = base.multiply(percentage); + return applyRoundingRules(rawCharge, digitsAfterDecimal, inMultiplesOf); + } + + private List getLoanCharges(Long loanId) { + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + return loanDetails.getCharges(); + } + + private List extractAndSortAmounts(List charges) { + return charges.stream().map(GetLoansLoanIdLoanChargeData::getAmount).sorted().toList(); + } + + private BigDecimal applyRoundingRules(BigDecimal amount, int digitsAfterDecimal, int inMultiplesOf) { + BigDecimal scaled; + + if (digitsAfterDecimal == 0) { + BigDecimal fractionPart = amount.remainder(BigDecimal.ONE); + + if (fractionPart.compareTo(new BigDecimal("0.5")) <= 0) { + scaled = amount.setScale(0, RoundingMode.DOWN); + } else { + scaled = amount.setScale(0, RoundingMode.UP); + } + } else { + scaled = amount.setScale(digitsAfterDecimal, RoundingMode.HALF_UP); + } + + if (digitsAfterDecimal == 0 && inMultiplesOf > 0) { + BigDecimal divisor = new BigDecimal(inMultiplesOf); + BigDecimal remainder = scaled.remainder(divisor); + + if (remainder.compareTo(BigDecimal.ZERO) != 0) { + scaled = scaled.add(divisor.subtract(remainder)); + } + } + return scaled; + } + + private List calculateExpectedTrancheCharges(List disbursements, String percentageAsDecimal, + int digitsAfterDecimal, int inMultiplesOf) { + BigDecimal percentage = new BigDecimal(percentageAsDecimal); + List expectedAmounts = new ArrayList<>(); + for (PostLoansDisbursementData disbursement : disbursements) { + BigDecimal rawCharge = disbursement.getPrincipal().multiply(percentage); + BigDecimal rounded = applyRoundingRules(rawCharge, digitsAfterDecimal, inMultiplesOf); + if (rounded.compareTo(BigDecimal.ZERO) != 0) { + expectedAmounts.add(rounded); + } + } + Collections.sort(expectedAmounts); + return expectedAmounts; + } + + private void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertEquals(0, expected.compareTo(actual)); + } + + private void assertBigDecimalListEquals(List expected, List actual) { + for (int i = 0; i < expected.size(); i++) { + assertEquals(0, expected.get(i).compareTo(actual.get(i))); + } + } + + /// AMOUNT + INTEREST + private Long createAndDisburseProgressiveLoan(double principal, int digitsAfterDecimal, int inMultiplesOf) { + PostLoanProductsResponse loanProduct = loanProductHelper.createLoanProduct( + create4IProgressive().numberOfRepayments(12).interestRatePerPeriod(1.0).isInterestRecalculationEnabled(false) + .currencyCode("USD").digitsAfterDecimal(digitsAfterDecimal).inMultiplesOf(inMultiplesOf)); + + PostLoansRequest request = applyLoanRequest(clientId, loanProduct.getResourceId(), DATE, principal, 12); + + request.setInterestRatePerPeriod(new BigDecimal("1.0")); + request.setInterestRateFrequencyType(1); + request.setTransactionProcessingStrategyCode("advanced-payment-allocation-strategy"); + + Long loanId = loanTransactionHelper.applyLoan(request).getLoanId(); + + loanTransactionHelper.approveLoan(loanId, approveLoanRequest(principal, DATE)); + + loanTransactionHelper.disburseLoan(loanId, DATE, principal); + + return loanId; + } + + private BigDecimal executeCobAndGetTotalInterest(Long loanId) { + inlineLoanCOBHelper.executeInlineCOB(loanId); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + + BigDecimal totalInterest = loanDetails.getRepaymentSchedule().getPeriods().stream() + .map(p -> p.getInterestDue() == null ? BigDecimal.ZERO : p.getInterestDue()).reduce(BigDecimal.ZERO, BigDecimal::add); + + assertTrue(totalInterest.compareTo(BigDecimal.ZERO) > 0); + + return totalInterest; + } + + private void assertNoChargesPersisted(Long loanId, Long chargeId) { + List charges = getLoanCharges(loanId); + + boolean chargeExists = charges != null && charges.stream().anyMatch(c -> Objects.equals(c.getChargeId(), chargeId)); + + assertFalse(chargeExists, "Expected charge not to persist since rounded amount becomes 0"); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountChargeRoundingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountChargeRoundingTest.java new file mode 100644 index 00000000000..3d0385b0363 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountChargeRoundingTest.java @@ -0,0 +1,325 @@ +/** + * 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.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.GetSavingsAccountsSavingsAccountIdChargesSavingsAccountChargeIdResponse; +import org.apache.fineract.client.models.PostChargesResponse; +import org.apache.fineract.client.models.PostSavingsAccountTransactionsRequest; +import org.apache.fineract.client.models.PostSavingsAccountTransactionsResponse; +import org.apache.fineract.client.models.PostSavingsAccountsSavingsAccountIdChargesRequest; +import org.apache.fineract.client.models.PostSavingsAccountsSavingsAccountIdChargesResponse; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.SavingsAccountData; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.apache.fineract.integrationtests.savings.base.BaseSavingsIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@Slf4j +public class SavingsAccountChargeRoundingTest extends BaseSavingsIntegrationTest { + + private Long clientId; + private ChargesHelper chargesHelper; + private static final String DATE = "01 January 2026"; + + @BeforeEach + public void setup() { + clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + chargesHelper = new ChargesHelper(); + } + + /** FLAT CHARGE **/ + @Test + public void shouldApplyRoundingRules_forFlatCharge() { + runAt(DATE, () -> { + + Long productId = createSavingsProduct(0, 1); + Long savingsId = createAndActivateSavingsAccount(productId, DATE); + + PostChargesResponse charge = createFlatCharge(19.8); + + Long savingsChargeId = addFlatCharge(savingsId, charge.getResourceId(), 19.8, DATE); + + BigDecimal actualChargeAmount = getSavingsChargeAmount(savingsId, savingsChargeId); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("19.8"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpFlatCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + + Long productId = createSavingsProduct(0, 1); + Long savingsId = createAndActivateSavingsAccount(productId, DATE); + + PostChargesResponse charge = createFlatCharge(0.6); + + Long savingsChargeId = addFlatCharge(savingsId, charge.getResourceId(), 0.6, DATE); + + BigDecimal actualChargeAmount = getSavingsChargeAmount(savingsId, savingsChargeId); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.6"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistFlatCharge_whenRoundedToZero() { + runAt(DATE, () -> { + + Long productId = createSavingsProduct(0, 1); + Long savingsId = createAndActivateSavingsAccount(productId, DATE); + + PostChargesResponse charge = createFlatCharge(0.4); + + Long savingsChargeId = addFlatCharge(savingsId, charge.getResourceId(), 0.4, DATE); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.4"), 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + assertNull(savingsChargeId, "Expected no charges since rounded amount becomes 0"); + }); + } + + /** PERCENTAGE OF WITHDRAWAL CHARGE **/ + @Test + public void shouldApplyRoundingRules_forPercentageOfWithdrawalCharge() { + runAt(DATE, () -> { + + Long productId = createSavingsProduct(0, 1); + Long savingsId = createAndActivateSavingsAccount(productId, DATE); + + PostChargesResponse charge = createPercentageWithdrawalCharge(2.5); + + addPercentageWithdrawalCharge(savingsId, charge.getResourceId(), 2.5); + + deposit(savingsId, DATE, BigDecimal.valueOf(1000)); + + withdraw(savingsId, DATE, BigDecimal.valueOf(615)); + + SavingsAccountData accountData = getSavingsAccount(savingsId); + + BigDecimal actualChargeFees = getActualChargeAmount(accountData); + assertNotNull(actualChargeFees); + + BigDecimal actualBalance = getActualBalance(accountData); + assertNotNull(actualBalance); + + BigDecimal expectedChargeFees = calculateExpectedPercentageCharge("615", "0.025", 0, 1); + + BigDecimal expectedBalance = calculateExpectedBalance("1000", "615", expectedChargeFees); + + assertBigDecimalEquals(expectedChargeFees, actualChargeFees); + assertBigDecimalEquals(expectedBalance, actualBalance); + }); + } + + @Test + public void shouldRoundUpPercentageOfWithdrawalCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + + Long productId = createSavingsProduct(0, 1); + Long savingsId = createAndActivateSavingsAccount(productId, DATE); + + PostChargesResponse charge = createPercentageWithdrawalCharge(2.5); + + addPercentageWithdrawalCharge(savingsId, charge.getResourceId(), 2.5); + + deposit(savingsId, DATE, BigDecimal.valueOf(1000)); + + withdraw(savingsId, DATE, BigDecimal.valueOf(24)); + + SavingsAccountData accountData = getSavingsAccount(savingsId); + + BigDecimal actualChargeFees = getActualChargeAmount(accountData); + assertNotNull(actualChargeFees); + + BigDecimal actualBalance = getActualBalance(accountData); + assertNotNull(actualBalance); + + BigDecimal expectedChargeFees = calculateExpectedPercentageCharge("24", "0.025", 0, 1); + + BigDecimal expectedBalance = calculateExpectedBalance("1000", "24", expectedChargeFees); + + assertBigDecimalEquals(expectedChargeFees, actualChargeFees); + assertBigDecimalEquals(expectedBalance, actualBalance); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeFees); + }); + } + + @Test + public void shouldNotPersistPercentageOfWithdrawalCharge_whenRoundedToZero() { + runAt(DATE, () -> { + + Long productId = createSavingsProduct(0, 1); + Long savingsId = createAndActivateSavingsAccount(productId, DATE); + + PostChargesResponse charge = createPercentageWithdrawalCharge(2.5); + + addPercentageWithdrawalCharge(savingsId, charge.getResourceId(), 2.5); + + deposit(savingsId, DATE, BigDecimal.valueOf(1000)); + + withdraw(savingsId, DATE, BigDecimal.valueOf(20)); + + SavingsAccountData accountData = getSavingsAccount(savingsId); + + BigDecimal actualChargeFees = getActualChargeAmount(accountData); + assertNull(actualChargeFees, "Expected no charges since rounded amount becomes 0"); + }); + } + + // ----------------------------- + // HELPERS + // ----------------------------- + + private Long createSavingsProduct(int digitsAfterDecimal, int inMultiplesOf) { + return createProduct(baseSavingsProduct(digitsAfterDecimal, inMultiplesOf)).getResourceId(); + } + + private PostSavingsProductsRequest baseSavingsProduct(int digitsAfterDecimal, int inMultiplesOf) { + return dailyInterestPostingProduct().digitsAfterDecimal(digitsAfterDecimal).inMultiplesOf(inMultiplesOf).currencyCode("USD"); + } + + private Long createAndActivateSavingsAccount(Long productId, String date) { + Long savingsId = applySavingsAccount(applySavingsRequest(clientId, productId, date)).getSavingsId(); + approveSavingsAccount(savingsId, date); + activateSavingsAccount(savingsId, date); + return savingsId; + } + + private PostChargesResponse createFlatCharge(double amount) { + return chargesHelper.createCharges(new ChargeRequest().name("Flat Charge " + UUID.randomUUID()).chargeAppliesTo(2) // SAVINGS + .chargeTimeType(2) // SPECIFIED DUE DATE + .chargeCalculationType(1) // FLAT + .amount(amount).currencyCode("USD").locale("en").active(true).penalty(false)); + } + + private PostChargesResponse createPercentageWithdrawalCharge(double percentage) { + return chargesHelper.createCharges(new ChargeRequest().name("Withdrawal Charge " + UUID.randomUUID()).chargeAppliesTo(2) // SAVINGS + .chargeTimeType(5) // WITHDRAWAL + .chargeCalculationType(2) // % OF AMOUNT + .amount(percentage).currencyCode("USD").locale("en").chargePaymentMode(0).active(true).penalty(false)); + } + + private Long addFlatCharge(Long savingsId, Long chargeId, double amount, String date) { + PostSavingsAccountsSavingsAccountIdChargesRequest request = new PostSavingsAccountsSavingsAccountIdChargesRequest() + .chargeId(chargeId).amount((float) amount).dateFormat(DATETIME_PATTERN).locale("en").dueDate(date); + + PostSavingsAccountsSavingsAccountIdChargesResponse response = ok( + fineractClient().savingsAccountCharges.addSavingsAccountCharge(savingsId, request)); + + return response.getResourceId(); + } + + private void addPercentageWithdrawalCharge(Long savingsId, Long chargeId, double amount) { + PostSavingsAccountsSavingsAccountIdChargesRequest request = new PostSavingsAccountsSavingsAccountIdChargesRequest() + .chargeId(chargeId).amount((float) amount).locale("en"); + + ok(fineractClient().savingsAccountCharges.addSavingsAccountCharge(savingsId, request)); + } + + private PostSavingsAccountTransactionsResponse withdraw(Long savingsId, String date, BigDecimal amount) { + PostSavingsAccountTransactionsRequest request = new PostSavingsAccountTransactionsRequest().dateFormat(DATETIME_PATTERN) + .locale("en").paymentTypeId(1).transactionAmount(amount).transactionDate(date); + + return ok(fineractClient().savingsTransactions.createSavingsAccountTransaction(savingsId, request, "withdrawal")); + } + + private GetSavingsAccountsSavingsAccountIdChargesSavingsAccountChargeIdResponse getSavingsAccountCharge(Long savingsId, + Long savingsAccountChargeId) { + return ok(fineractClient().savingsAccountCharges.retrieveSavingsAccountCharge(savingsId, savingsAccountChargeId)); + } + + private BigDecimal applyRoundingRules(BigDecimal amount, int digitsAfterDecimal, int inMultiplesOf) { + BigDecimal scaled; + + if (digitsAfterDecimal == 0) { + BigDecimal fractionPart = amount.remainder(BigDecimal.ONE); + + if (fractionPart.compareTo(new BigDecimal("0.5")) <= 0) { + scaled = amount.setScale(0, RoundingMode.DOWN); + } else { + scaled = amount.setScale(0, RoundingMode.UP); + } + } else { + scaled = amount.setScale(digitsAfterDecimal, RoundingMode.HALF_UP); + } + + if (digitsAfterDecimal == 0 && inMultiplesOf > 0) { + BigDecimal divisor = new BigDecimal(inMultiplesOf); + BigDecimal remainder = scaled.remainder(divisor); + + if (remainder.compareTo(BigDecimal.ZERO) != 0) { + scaled = scaled.add(divisor.subtract(remainder)); + } + } + return scaled; + } + + private BigDecimal getSavingsChargeAmount(Long savingsId, Long savingsChargeId) { + + var chargeData = getSavingsAccountCharge(savingsId, savingsChargeId); + + assertNotNull(chargeData); + assertNotNull(chargeData.getAmount()); + + return BigDecimal.valueOf(chargeData.getAmount().doubleValue()); + } + + private void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertEquals(0, expected.compareTo(actual)); + } + + private BigDecimal getActualChargeAmount(SavingsAccountData accountData) { + assertNotNull(accountData.getSummary()); + return accountData.getSummary().getTotalWithdrawalFees(); + } + + private BigDecimal getActualBalance(SavingsAccountData accountData) { + assertNotNull(accountData.getSummary()); + return accountData.getSummary().getAccountBalance(); + } + + private BigDecimal calculateExpectedPercentageCharge(String baseAmount, String percentageAsDecimal, int digitsAfterDecimal, + int inMultiplesOf) { + BigDecimal base = new BigDecimal(baseAmount); + BigDecimal percentage = new BigDecimal(percentageAsDecimal); + BigDecimal rawCharge = base.multiply(percentage); + return applyRoundingRules(rawCharge, digitsAfterDecimal, inMultiplesOf); + } + + private BigDecimal calculateExpectedBalance(String deposit, String withdraw, BigDecimal expectedChargeFees) { + return new BigDecimal(deposit).subtract(new BigDecimal(withdraw)).subtract(expectedChargeFees); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ShareAccountChargeRoundingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ShareAccountChargeRoundingTest.java new file mode 100644 index 00000000000..29aa57345bf --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ShareAccountChargeRoundingTest.java @@ -0,0 +1,534 @@ +/** + * 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.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.AccountChargesRequest; +import org.apache.fineract.client.models.AccountRequest; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.GetAccountsCharges; +import org.apache.fineract.client.models.GetAccountsTypeAccountIdResponse; +import org.apache.fineract.client.models.PostAccountsTypeAccountIdRequest; +import org.apache.fineract.client.models.PostChargesResponse; +import org.apache.fineract.client.models.PostProductsTypeRequest; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.apache.fineract.integrationtests.savings.base.BaseSavingsIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@Slf4j +public class ShareAccountChargeRoundingTest extends BaseSavingsIntegrationTest { + + private Long clientId; + private ChargesHelper chargesHelper; + private static final String DATE = "01 January 2026"; + private static final String LATER_DATE = "01 June 2026"; + + @BeforeEach + public void setup() { + clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + chargesHelper = new ChargesHelper(); + } + + /** ACTIVATION CHARGE - FLAT **/ + @Test + public void shouldApplyRoundingRules_forFlatActivationCharge() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createActivationFeeFlatCharge(19.8); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 19.8, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("19.8"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpFlatActivationCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createActivationFeeFlatCharge(0.55); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.55, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.55"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistFlatActivationCharge_whenRoundedToZero() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createActivationFeeFlatCharge(0.5); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.5, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.5"), 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoShareChargesPersisted(shareAccountId, chargeResponse.getResourceId()); + }); + } + + /** PURCHASE CHARGE - FLAT **/ + @Test + public void shouldApplyRoundingRules_forFlatPurchaseCharge() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 3); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createPurchaseFeeFlatCharge(19.8); + + Long shareProductId = createShareProduct(0, 3); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 19.8, DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("19.8"), 0, 3); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpFlatPurchaseCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createPurchaseFeeFlatCharge(0.51); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.51, DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.51"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistFlatPurchaseCharge_whenRoundedToZero() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createPurchaseFeeFlatCharge(0.5); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.5, DATE); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.5"), 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoShareChargesPersisted(shareAccountId, chargeResponse.getResourceId()); + }); + } + + /** PURCHASE CHARGE - PERCENTAGE **/ + @Test + public void shouldApplyRoundingRules_forPercentagePurchaseCharge() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createPurchaseFeePercentCharge(2.5); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 2.5, DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("100", "0.025", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpPercentagePurchaseCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createPurchaseFeePercentCharge(0.51); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.51, DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("100", "0.0051", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistPercentagePurchaseCharge_whenRoundedToZero() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createPurchaseFeePercentCharge(0.5); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.5, DATE); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("100", "0.005", 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoShareChargesPersisted(shareAccountId, chargeResponse.getResourceId()); + }); + } + + /** REDEEM CHARGE - FLAT **/ + @Test + public void shouldApplyRoundingRules_forFlatRedeemCharge() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createRedeemFeeFlatCharge(10.7); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 10.7, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + redeemShares(shareAccountId, 50, LATER_DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("10.7"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpFlatRedeemCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createRedeemFeeFlatCharge(0.6); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.6, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + redeemShares(shareAccountId, 50, LATER_DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.6"), 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistFlatRedeemCharge_whenRoundedToZero() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createRedeemFeeFlatCharge(0.5); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 0.5, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + redeemShares(shareAccountId, 50, LATER_DATE); + + BigDecimal expectedChargeAmount = applyRoundingRules(new BigDecimal("0.5"), 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoShareChargesPersisted(shareAccountId, chargeResponse.getResourceId()); + }); + } + + /** REDEEM CHARGE - PERCENT **/ + @Test + public void shouldApplyRoundingRules_forPercentageRedeemCharge() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createRedeemFeePercentCharge(5.5); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 5.5, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + redeemShares(shareAccountId, 50, LATER_DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("50", "0.055", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + }); + } + + @Test + public void shouldRoundUpPercentageRedeemCharge_whenValueIsAboveHalf() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createRedeemFeePercentCharge(1.5); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 1.5, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + redeemShares(shareAccountId, 50, LATER_DATE); + + BigDecimal actualChargeAmount = getShareChargeAmount(shareAccountId, chargeResponse.getResourceId()); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("50", "0.015", 0, 1); + + assertBigDecimalEquals(expectedChargeAmount, actualChargeAmount); + assertBigDecimalEquals(BigDecimal.ONE, actualChargeAmount); + }); + } + + @Test + public void shouldNotPersistPercentageRedeemCharge_whenRoundedToZero() { + runAt(DATE, () -> { + Long savingsProductId = createSavingsProduct(0, 1); + Long savingsAccountId = createAndActivateSavingsAccount(savingsProductId, DATE); + + PostChargesResponse chargeResponse = createRedeemFeePercentCharge(1); + + Long shareProductId = createShareProduct(0, 1); + Long shareAccountId = applyShareAccount(clientId, shareProductId, savingsAccountId, chargeResponse.getResourceId(), 1, DATE); + approveShareAccount(shareAccountId); + activateShareAccount(shareAccountId, DATE); + redeemShares(shareAccountId, 50, LATER_DATE); + + BigDecimal expectedChargeAmount = calculateExpectedPercentageCharge("50", "0.01", 0, 1); + assertBigDecimalEquals(BigDecimal.ZERO, expectedChargeAmount); + + assertNoShareChargesPersisted(shareAccountId, chargeResponse.getResourceId()); + }); + } + + // ----------------------------- + // HELPERS + // ----------------------------- + + private Long createSavingsProduct(int digitsAfterDecimal, int inMultiplesOf) { + return createProduct(baseSavingsProduct(digitsAfterDecimal, inMultiplesOf)).getResourceId(); + } + + private PostSavingsProductsRequest baseSavingsProduct(int digitsAfterDecimal, int inMultiplesOf) { + return dailyInterestPostingProduct().digitsAfterDecimal(digitsAfterDecimal).inMultiplesOf(inMultiplesOf).currencyCode("USD"); + } + + private Long createAndActivateSavingsAccount(Long productId, String date) { + Long savingsId = applySavingsAccount(applySavingsRequest(clientId, productId, date)).getSavingsId(); + approveSavingsAccount(savingsId, date); + activateSavingsAccount(savingsId, date); + return savingsId; + } + + private PostChargesResponse createActivationFeeFlatCharge(double amount) { + return chargesHelper.createCharges(new ChargeRequest().name("Share Activation Fee Flat " + UUID.randomUUID()).chargeAppliesTo(4) // SHARE + .chargeTimeType(13) // ACTIVATION + .chargeCalculationType(1) // FLAT + .amount(amount).currencyCode("USD").locale("en").active(true).penalty(false)); + } + + private PostChargesResponse createPurchaseFeeFlatCharge(double amount) { + return chargesHelper.createCharges(new ChargeRequest().name("Share Purchase Fee Flat " + UUID.randomUUID()).chargeAppliesTo(4) // SHARE + .chargeTimeType(14) // PURCHASE + .chargeCalculationType(1) // FLAT + .amount(amount).currencyCode("USD").locale("en").active(true).penalty(false)); + } + + private PostChargesResponse createPurchaseFeePercentCharge(double amount) { + return chargesHelper.createCharges(new ChargeRequest().name("Share Purchase Fee Percent " + UUID.randomUUID()).chargeAppliesTo(4) // SHARE + .chargeTimeType(14) // PURCHASE + .chargeCalculationType(2) // PERCENT + .amount(amount).currencyCode("USD").locale("en").active(true).penalty(false)); + } + + private PostChargesResponse createRedeemFeeFlatCharge(double amount) { + return chargesHelper.createCharges(new ChargeRequest().name("Share Redeem Fee Flat " + UUID.randomUUID()).chargeAppliesTo(4) // SHARE + .chargeTimeType(15) // REDEEM + .chargeCalculationType(1) // FLAT + .amount(amount).currencyCode("USD").locale("en").active(true).penalty(false)); + } + + private PostChargesResponse createRedeemFeePercentCharge(double amount) { + return chargesHelper.createCharges(new ChargeRequest().name("Share Redeem Fee Percent " + UUID.randomUUID()).chargeAppliesTo(4) // SHARE + .chargeTimeType(15) // REDEEM + .chargeCalculationType(2) // PERCENT + .amount(amount).currencyCode("USD").locale("en").active(true).penalty(false)); + } + + private Long createShareProduct(int digitsAfterDecimal, int inMultiplesOf) { + + PostProductsTypeRequest request = new PostProductsTypeRequest().name("Share Product " + UUID.randomUUID()).shortName("SP") + .description("Description").currencyCode("USD").digitsAfterDecimal(digitsAfterDecimal).inMultiplesOf(inMultiplesOf) + .locale("en").totalShares(1000).unitPrice(1).nominalShares(20).allowDividendCalculationForInactiveClients(true) + .accountingRule(1); + + return ok(fineractClient().shareProducts.createShareProduct("share", request)).getResourceId(); + } + + private Long applyShareAccount(Long clientId, Long productId, Long savingsAccountId, Long chargeId, double chargeAmount, String date) { + AccountChargesRequest charge = new AccountChargesRequest().chargeId(chargeId).amount(new BigDecimal(chargeAmount)); + + AccountRequest request = new AccountRequest().clientId(clientId).productId(productId).submittedDate(date).locale("en") + .dateFormat(DATETIME_PATTERN).savingsAccountId(savingsAccountId).requestedShares(100L).applicationDate(date) + .charges(List.of(charge)); + + return ok(fineractClient().shareAccounts.createShareAccount("share", request)).getResourceId(); + } + + private void approveShareAccount(Long shareAccountId) { + ok(fineractClient().shareAccounts.handleCommandsShareAccount("share", shareAccountId, new PostAccountsTypeAccountIdRequest(), + "approve")); + } + + private void activateShareAccount(Long shareAccountId, String date) { + PostAccountsTypeAccountIdRequest request = new PostAccountsTypeAccountIdRequest().locale("en").dateFormat("dd MMMM yyyy") + .activatedDate(date); + + ok(fineractClient().shareAccounts.handleCommandsShareAccount("share", shareAccountId, request, "activate")); + } + + private void redeemShares(Long shareAccountId, long shares, String date) { + PostAccountsTypeAccountIdRequest request = new PostAccountsTypeAccountIdRequest().locale("en").dateFormat("dd MMMM yyyy") + .requestedDate(date).requestedShares(shares); + + ok(fineractClient().shareAccounts.handleCommandsShareAccount("share", shareAccountId, request, "redeemshares")); + } + + private GetAccountsTypeAccountIdResponse getShareAccount(Long shareAccountId) { + return ok(fineractClient().shareAccounts.retrieveOneShareAccount(shareAccountId, "share")); + } + + private BigDecimal getShareChargeAmount(Long shareAccountId, Long chargeId) { + GetAccountsTypeAccountIdResponse response = getShareAccount(shareAccountId); + + Set charges = response.getCharges(); + assertNotNull(charges); + + GetAccountsCharges charge = charges.stream().filter(c -> Objects.equals(c.getChargeId(), chargeId)).findFirst() + .orElseThrow(() -> new AssertionError("Share charge not found: " + chargeId)); + + BigDecimal amount = BigDecimal.valueOf(charge.getAmount()); + assertNotNull(amount); + + return amount; + } + + private BigDecimal applyRoundingRules(BigDecimal amount, int digitsAfterDecimal, int inMultiplesOf) { + BigDecimal scaled; + + if (digitsAfterDecimal == 0) { + BigDecimal fractionPart = amount.remainder(BigDecimal.ONE); + + if (fractionPart.compareTo(new BigDecimal("0.5")) <= 0) { + scaled = amount.setScale(0, RoundingMode.DOWN); + } else { + scaled = amount.setScale(0, RoundingMode.UP); + } + } else { + scaled = amount.setScale(digitsAfterDecimal, RoundingMode.HALF_UP); + } + + if (digitsAfterDecimal == 0 && inMultiplesOf > 0) { + BigDecimal divisor = new BigDecimal(inMultiplesOf); + BigDecimal remainder = scaled.remainder(divisor); + + if (remainder.compareTo(BigDecimal.ZERO) != 0) { + scaled = scaled.add(divisor.subtract(remainder)); + } + } + return scaled; + } + + private BigDecimal calculateExpectedPercentageCharge(String baseAmount, String percentageAsDecimal, int digitsAfterDecimal, + int inMultiplesOf) { + BigDecimal base = new BigDecimal(baseAmount); + BigDecimal percentage = new BigDecimal(percentageAsDecimal); + BigDecimal rawCharge = base.multiply(percentage); + return applyRoundingRules(rawCharge, digitsAfterDecimal, inMultiplesOf); + } + + private void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertEquals(0, expected.compareTo(actual)); + } + + private void assertNoShareChargesPersisted(Long shareAccountId, Long chargeId) { + GetAccountsTypeAccountIdResponse response = getShareAccount(shareAccountId); + + Set charges = response.getCharges(); + + boolean chargeExists = charges != null && charges.stream().anyMatch(c -> Objects.equals(c.getChargeId(), chargeId)); + + assertFalse(chargeExists, "Expected charge not to persist since rounded amount becomes 0"); + } +}