From 92aa4cd469c8c61c841ce4b526a2a8959f0064c7 Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Fri, 10 Apr 2026 09:16:28 -0500 Subject: [PATCH] FINERACT-1289: Taxes on Loan charges --- fineract-client/build.gradle | 17 + .../src/docs/en/chapters/features/index.adoc | 1 + .../features/taxes-on-loan-charges.adoc | 128 ++++ .../module/fineract-investor/persistence.xml | 1 + .../fineract-loan-origination/persistence.xml | 1 + .../loanaccount/data/ChargeTaxDetailDTO.java | 40 ++ .../loanaccount/data/LoanChargePaidByDTO.java | 3 + .../loanaccount/domain/LoanCharge.java | 10 + .../domain/LoanChargeTaxDetails.java | 54 ++ .../service/LoanChargeService.java | 28 + .../module/loan/module-changelog-master.xml | 1 + .../parts/1035_add_tax_to_loan_charge.xml | 55 ++ .../module/fineract-loan/persistence.xml | 1 + ...epaymentScheduleProcessingWrapperTest.java | 3 +- .../service/LoanChargeServiceTaxTest.java | 247 ++++++++ .../fineract-progressive-loan/persistence.xml | 1 + .../data/ChargeTaxPaymentDTO.java | 33 ++ .../journalentry/data/LoanTransactionDTO.java | 5 + .../service/AccountingProcessorHelper.java | 22 + ...ccrualBasedAccountingProcessorForLoan.java | 105 ++-- .../CashBasedAccountingProcessorForLoan.java | 72 +-- ...WritePlatformServiceJpaRepositoryImpl.java | 26 +- .../service/LoanCommonAccountingHelper.java | 122 ++++ .../starter/LoanAccountConfiguration.java | 6 +- .../module/fineract-provider/persistence.xml | 1 + ...ntDelinquencyRangeEventSerializerTest.java | 3 +- .../service/ChargeTaxApplicationService.java | 30 + .../ChargeTaxApplicationServiceImpl.java | 40 ++ .../ChargeTaxApplicationServiceTest.java | 246 ++++++++ .../persistence.xml | 1 + .../LoanChargeTaxIntegrationTest.java | 554 ++++++++++++++++++ 31 files changed, 1772 insertions(+), 85 deletions(-) create mode 100644 fineract-doc/src/docs/en/chapters/features/taxes-on-loan-charges.adoc create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/ChargeTaxDetailDTO.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeTaxDetails.java create mode 100644 fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1035_add_tax_to_loan_charge.xml create mode 100644 fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeServiceTaxTest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/ChargeTaxPaymentDTO.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LoanCommonAccountingHelper.java create mode 100644 fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationService.java create mode 100644 fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceImpl.java create mode 100644 fineract-tax/src/test/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeTaxIntegrationTest.java diff --git a/fineract-client/build.gradle b/fineract-client/build.gradle index 24025ae321d..45c731ddeb6 100644 --- a/fineract-client/build.gradle +++ b/fineract-client/build.gradle @@ -103,6 +103,23 @@ task buildTypescriptAngularSdk(type: org.openapitools.generator.gradle.plugin.ta dependsOn(':fineract-provider:resolve') } +task buildTypescriptFetchSdk(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask) { + generatorName = 'typescript-fetch' + verbose = false + validateSpec = false + skipValidateSpec = true + inputSpec = "file:///$swaggerFile" + outputDir = "$buildDir/generated/typescript-fetch".toString() + configOptions = [ + npmName: '@apache/fineract-client-fetch', + npmVersion: '1.12.0-SNAPSHOT', + typescriptThreePlus: 'true', + supportsES6: 'true', + withInterfaces: 'true' + ] + dependsOn(':fineract-provider:resolve') +} + task buildAsciidoc(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask){ generatorName = 'asciidoc' verbose = false diff --git a/fineract-doc/src/docs/en/chapters/features/index.adoc b/fineract-doc/src/docs/en/chapters/features/index.adoc index 042577f9c67..545a9923ff8 100644 --- a/fineract-doc/src/docs/en/chapters/features/index.adoc +++ b/fineract-doc/src/docs/en/chapters/features/index.adoc @@ -18,3 +18,4 @@ include::delayed-schedule-captures.adoc[leveloffset=+1] include::loan-origination-details.adoc[leveloffset=+1] include::working-capital-amortization-schedule.adoc[leveloffset=+1] include::working-capital-credit-balance-refund.adoc[leveloffset=+1] +include::taxes-on-loan-charges.adoc[leveloffset=+1] diff --git a/fineract-doc/src/docs/en/chapters/features/taxes-on-loan-charges.adoc b/fineract-doc/src/docs/en/chapters/features/taxes-on-loan-charges.adoc new file mode 100644 index 00000000000..a8a742328e4 --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/taxes-on-loan-charges.adoc @@ -0,0 +1,128 @@ += Taxes on Loan Charges + +[NOTE] +==== +Introduced in link:https://issues.apache.org/jira/browse/FINERACT-1289[FINERACT-1289] — Tax component not working as expected: tax bifurcation in journal entries was not applied when a tax group was mapped to a loan charge. +==== + +== Overview + +When a loan charge (fee or penalty) is linked to a tax group, the system must produce separate journal entry lines for the base charge amount and the tax portion. Prior to this fix, the tax bifurcation was silently skipped and the full charge amount was posted as a single income entry, causing incorrect GL balances and tax liability omissions. + +This fix ensures that every time a loan charge amount is set or recalculated, any configured tax group is evaluated, the tax split is computed per tax component, and both the net charge amount and the tax liability are recorded as distinct journal entries under both accrual-based and cash-based accounting modes. + +=== Benefits + +* Tax liability GL accounts are credited correctly when a taxed charge is collected. +* Income accounts reflect the net-of-tax charge amount rather than the gross amount. +* Per-component tax breakdowns are persisted, enabling audit trails and reporting. +* Both accrual and cash accounting modes handle tax bifurcation consistently. + +== Design + +=== Key Components + +|=== +| Component | Module | Purpose + +| `LoanChargeTaxDetails` +| `fineract-loan` +| JPA entity (`m_loan_charge_tax_details`) storing the computed tax amount per `TaxComponent` for a single `LoanCharge`. + +| `ChargeTaxApplicationService` / `ChargeTaxApplicationServiceImpl` +| `fineract-tax` +| Computes the per-component tax split from a `TaxGroup`, a base amount, and an effective date. + +| `LoanChargeService` +| `fineract-loan` +| Calls `applyTaxIfConfigured()` after every charge amount mutation (set, update, recalculate) to keep tax details in sync. + +| `ChargeTaxPaymentDTO` +| `fineract-provider` +| Carries per-component tax payment data (charge ID, credit GL account ID, amount, penalty flag) from the loan transaction to the accounting layer. + +| `LoanCommonAccountingHelper` +| `fineract-provider` +| Shared helper that filters tax payments by type (fee/penalty), computes net charge amounts, and creates credit/debit journal entries for tax liability accounts. + +| `AccrualBasedAccountingProcessorForLoan` +| `fineract-provider` +| Extended to split fee and penalty journal entries into net income and tax liability entries when tax payments are present. + +| `CashBasedAccountingProcessorForLoan` +| `fineract-provider` +| Extended with the same tax bifurcation logic for cash-basis accounting. +|=== + +=== Data Model + +Two schema changes are introduced by migration `1035_add_tax_to_loan_charge.xml`. + +[source,sql] +---- +-- Column added to m_loan_charge to store the total tax amount for the charge +ALTER TABLE m_loan_charge + ADD COLUMN tax_amount DECIMAL(19,6) NULL; + +-- New table storing the per-component tax breakdown for each loan charge +CREATE TABLE m_loan_charge_tax_details ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + loan_charge_id BIGINT NOT NULL, -- FK → m_loan_charge.id + tax_component_id BIGINT NOT NULL, -- FK → m_tax_component.id + amount DECIMAL(19,6) NOT NULL +); +---- + +=== Accounting Impact + +When a loan transaction pays a charge that has associated tax, the journal entries are split between the net charge amount and the tax liability. + +==== Accrual-Based Accounting — Fee Payment + +|=== +| Side | Account | Amount + +| DR | Fees Receivable | Full fee amount (including tax) +| CR | Income from Fees | Net fee amount (fee minus tax) +| CR | Tax Liability (per component) | Tax amount +|=== + +==== Accrual-Based Accounting — Fee Reversal + +|=== +| Side | Account | Amount + +| CR | Fees Receivable | Full fee amount (including tax) +| DR | Income from Fees | Net fee amount +| DR | Tax Liability (per component) | Tax amount +|=== + +The same split applies to penalty charges using the penalty income and receivable accounts. Cash-based accounting follows the same debit/credit rules without the receivable leg. + +If no tax group is configured on the charge, or the computed tax is zero, the existing single-entry behaviour is preserved. + +== Configuration + +=== Prerequisites + +. Create one or more *Tax Components* (Administration → Tax Configuration → Tax Components) with the applicable percentage rate. +. Create a *Tax Group* that references the tax components. +. On the *Charge product*, assign the tax group under the _Tax Group_ field. +. Add the charge to a *Loan Product* that has either *Periodic Accrual* or *Cash-Based* accounting enabled. + +=== Validation Rules + +* Tax is computed at the time the charge amount is set or updated; a later change to the charge amount triggers recomputation. +* The effective date for tax rate lookup defaults to the charge submission date, falling back to the current business date when the submission date is absent. +* If the tax group yields a zero total tax (e.g., all components have 0% rate on the effective date), no tax entries are created and the charge behaves as untaxed. + +== Usage Example + +. Configure a tax component "VAT 16%" and a tax group "Standard VAT". +. Create a flat loan fee of 1,000 and link it to the "Standard VAT" group. +. Add the fee to a loan product with periodic accrual accounting. +. After disbursement, the fee appears with `amount = 1,000` and `tax_amount = 160`. +. When the borrower repays the charge the system posts: +** DR Fees Receivable 1,000 +** CR Income from Fees 840 +** CR Tax Liability 160 diff --git a/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml b/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml index 5489bb2af7c..8dddaaafc7e 100644 --- a/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml +++ b/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml @@ -113,6 +113,7 @@ org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory org.apache.fineract.portfolio.loanaccount.domain.LoanCharge org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy + org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails diff --git a/fineract-loan-origination/src/main/resources/jpa/static-weaving/module/fineract-loan-origination/persistence.xml b/fineract-loan-origination/src/main/resources/jpa/static-weaving/module/fineract-loan-origination/persistence.xml index 6701aff8332..791a3a8bd9a 100644 --- a/fineract-loan-origination/src/main/resources/jpa/static-weaving/module/fineract-loan-origination/persistence.xml +++ b/fineract-loan-origination/src/main/resources/jpa/static-weaving/module/fineract-loan-origination/persistence.xml @@ -103,6 +103,7 @@ org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory org.apache.fineract.portfolio.loanaccount.domain.LoanCharge org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy + org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/ChargeTaxDetailDTO.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/ChargeTaxDetailDTO.java new file mode 100644 index 00000000000..4a5dfb67d32 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/ChargeTaxDetailDTO.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.data; + +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Carries the pro-rated tax amount for a single TaxComponent when a LoanCharge is (partially) paid. Used to propagate + * tax details from the domain layer to the accounting bridge. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChargeTaxDetailDTO { + + /** GL account to credit (tax liability account from TaxComponent.creditAccount). */ + private Long creditAccountId; + + /** Pro-rated tax amount for this component in this payment. */ + private BigDecimal amount; +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanChargePaidByDTO.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanChargePaidByDTO.java index bd281ca6aa9..6e2c130bdd2 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanChargePaidByDTO.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanChargePaidByDTO.java @@ -19,6 +19,8 @@ package org.apache.fineract.portfolio.loanaccount.data; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -33,5 +35,6 @@ public class LoanChargePaidByDTO { private Long loanChargeId; private BigDecimal amount; private Integer installmentNumber; + private List taxDetails = new ArrayList<>(); } 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 36101f67ae8..7f5a52723d5 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 @@ -108,6 +108,9 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "amount_outstanding_derived", scale = 6, precision = 19, nullable = false) private BigDecimal amountOutstanding; + @Column(name = "tax_amount", scale = 6, precision = 19) + private BigDecimal taxAmount = BigDecimal.ZERO; + @Column(name = "is_penalty", nullable = false) private boolean penaltyCharge = false; @@ -143,6 +146,9 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom { @OneToMany(mappedBy = "loanCharge", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private Set loanChargePaidBySet = new HashSet<>(); + @OneToMany(mappedBy = "loanCharge", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List taxDetails = new ArrayList<>(); + public void markAsFullyPaid() { this.amountPaid = this.amount; this.amountOutstanding = BigDecimal.ZERO; @@ -412,6 +418,10 @@ public Money getAmountWrittenOff(final MonetaryCurrency currency) { return Money.of(currency, this.amountWrittenOff); } + public Money getTaxAmount(final MonetaryCurrency currency) { + return Money.of(currency, getTaxAmount()); + } + /** * @param incrementBy * diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeTaxDetails.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeTaxDetails.java new file mode 100644 index 00000000000..363188e1bab --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeTaxDetails.java @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; + +@AllArgsConstructor +@Setter +@Getter +@Entity +@Table(name = "m_loan_charge_tax_details") +public class LoanChargeTaxDetails extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "loan_charge_id", nullable = false) + private LoanCharge loanCharge; + + @ManyToOne + @JoinColumn(name = "tax_component_id", nullable = false) + private TaxComponent taxComponent; + + @Column(name = "amount", scale = 6, precision = 19, nullable = false) + private BigDecimal amount; + + public LoanChargeTaxDetails() { + + } +} 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 5f439b6c74b..3e1d256cad3 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 @@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy; +import org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails; import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails; import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent; import org.apache.fineract.portfolio.loanaccount.domain.LoanInstallmentCharge; @@ -56,6 +57,10 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxGroup; +import org.apache.fineract.portfolio.tax.service.ChargeTaxApplicationService; +import org.apache.fineract.portfolio.tax.service.TaxUtils; @RequiredArgsConstructor public class LoanChargeService { @@ -65,6 +70,7 @@ public class LoanChargeService { private final LoanLifecycleStateMachine loanLifecycleStateMachine; private final LoanBalanceService loanBalanceService; private final LoanScheduleGeneratorService loanScheduleGeneratorService; + private final ChargeTaxApplicationService chargeTaxApplicationService; public void recalculateAllCharges(final Loan loan) { Set charges = loan.getActiveCharges(); @@ -393,6 +399,7 @@ public Map update(final JsonCommand command, final BigDecimal am break; } loanCharge.setAmountOrPercentage(newValue); + applyTaxIfConfigured(loanCharge); if (loanCharge.isInstalmentFee()) { updateInstallmentCharges(loanCharge); } @@ -445,11 +452,30 @@ public void populateDerivedFields(final LoanCharge loanCharge, final BigDecimal break; } loanCharge.setAmountOrPercentage(chargeAmount); + applyTaxIfConfigured(loanCharge); if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) { updateInstallmentCharges(loanCharge); } } + private void applyTaxIfConfigured(final LoanCharge loanCharge) { + TaxGroup taxGroup = loanCharge.getCharge().getTaxGroup(); + if (taxGroup == null || loanCharge.getAmount() == null) { + return; + } + LocalDate effectiveDate = loanCharge.getSubmittedOnDate() != null ? loanCharge.getSubmittedOnDate() + : DateUtils.getBusinessLocalDate(); + Map taxSplit = chargeTaxApplicationService.computeTax(taxGroup, loanCharge.getAmount(), effectiveDate, 6); + BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); + if (totalTax.compareTo(BigDecimal.ZERO) == 0) { + return; + } + loanCharge.setTaxAmount(totalTax); + loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding()); + loanCharge.getTaxDetails().clear(); + taxSplit.forEach((component, taxAmt) -> loanCharge.getTaxDetails().add(new LoanChargeTaxDetails(loanCharge, component, taxAmt))); + } + public void update(final LoanCharge loanCharge, final BigDecimal amount, final LocalDate dueDate, final Integer numberOfRepayments) { BigDecimal amountPercentageAppliedTo = BigDecimal.ZERO; if (loanCharge.getLoan() != null) { @@ -814,6 +840,7 @@ private void update(final LoanCharge loanCharge, final BigDecimal amount, final break; } loanCharge.setAmountOrPercentage(amount); + applyTaxIfConfigured(loanCharge); loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding()); if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) { updateInstallmentCharges(loanCharge); @@ -854,6 +881,7 @@ private void update(final LoanCharge loanCharge, final BigDecimal amount, final break; } loanCharge.setAmountOrPercentage(amount); + applyTaxIfConfigured(loanCharge); loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding()); if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) { updateInstallmentCharges(loanCharge, transactionDate); diff --git a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml index e086e3124da..63c6a7f1766 100644 --- a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml +++ b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml @@ -57,4 +57,5 @@ + diff --git a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1035_add_tax_to_loan_charge.xml b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1035_add_tax_to_loan_charge.xml new file mode 100644 index 00000000000..462aabf6dcd --- /dev/null +++ b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1035_add_tax_to_loan_charge.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml b/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml index e25219d7750..06e02775085 100644 --- a/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml +++ b/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml @@ -103,6 +103,7 @@ org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory org.apache.fineract.portfolio.loanaccount.domain.LoanCharge org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy + org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails diff --git a/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/SingleLoanChargeRepaymentScheduleProcessingWrapperTest.java b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/SingleLoanChargeRepaymentScheduleProcessingWrapperTest.java index 6d5fd8f8787..ab22c608098 100644 --- a/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/SingleLoanChargeRepaymentScheduleProcessingWrapperTest.java +++ b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/SingleLoanChargeRepaymentScheduleProcessingWrapperTest.java @@ -48,6 +48,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanChargeService; import org.apache.fineract.portfolio.loanaccount.service.LoanScheduleGeneratorService; import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionProcessingService; +import org.apache.fineract.portfolio.tax.service.ChargeTaxApplicationService; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -71,7 +72,7 @@ public class SingleLoanChargeRepaymentScheduleProcessingWrapperTest { private final LoanChargeService loanChargeService = new LoanChargeService(mock(LoanChargeValidator.class), mock(LoanTransactionProcessingService.class), mock(LoanLifecycleStateMachine.class), mock(LoanBalanceService.class), - mock(LoanScheduleGeneratorService.class)); + mock(LoanScheduleGeneratorService.class), mock(ChargeTaxApplicationService.class)); @BeforeAll public static void init() { diff --git a/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeServiceTaxTest.java b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeServiceTaxTest.java new file mode 100644 index 00000000000..b40fdbc3bf1 --- /dev/null +++ b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeServiceTaxTest.java @@ -0,0 +1,247 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.Collections; +import java.util.Map; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType; +import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails; +import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine; +import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxGroup; +import org.apache.fineract.portfolio.tax.service.ChargeTaxApplicationService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class LoanChargeServiceTaxTest { + + private static MockedStatic moneyHelperMock; + private static MockedStatic dateUtilsMock; + + private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 4, 10); + + @BeforeAll + static void setUpStatics() { + moneyHelperMock = mockStatic(MoneyHelper.class); + moneyHelperMock.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.HALF_EVEN); + moneyHelperMock.when(MoneyHelper::getMathContext).thenReturn(new MathContext(12, RoundingMode.HALF_EVEN)); + + dateUtilsMock = mockStatic(DateUtils.class); + dateUtilsMock.when(DateUtils::getBusinessLocalDate).thenReturn(BUSINESS_DATE); + } + + @AfterAll + static void tearDownStatics() { + moneyHelperMock.close(); + dateUtilsMock.close(); + } + + private LoanChargeService buildService(ChargeTaxApplicationService taxService) { + return new LoanChargeService(mock(LoanChargeValidator.class), mock(LoanTransactionProcessingService.class), + mock(LoanLifecycleStateMachine.class), mock(LoanBalanceService.class), mock(LoanScheduleGeneratorService.class), + taxService); + } + + @Test + void populateDerivedFields_doesNotApplyTax_whenChargeHasNoTaxGroup() { + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + LoanChargeService service = buildService(taxService); + + Charge charge = flatCharge(null /* no tax group */); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("100.00"), null); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("100.00"), null, BigDecimal.ZERO); + + assertThat(loanCharge.getTaxAmount()).isZero(); + assertThat(loanCharge.getTaxDetails()).isEmpty(); + } + + @Test + void populateDerivedFields_doesNotInflateAmount_whenTaxGroupIsConfigured() { + // base = 1000, tax = 160 (16 %) → amount stays 1000, taxAmount = 160 + TaxComponent component = mock(TaxComponent.class); + TaxGroup taxGroup = mock(TaxGroup.class); + + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + when(taxService.computeTax(any(TaxGroup.class), any(BigDecimal.class), any(LocalDate.class), anyInt())) + .thenReturn(Map.of(component, new BigDecimal("160.000000"))); + + LoanChargeService service = buildService(taxService); + + Charge charge = flatCharge(taxGroup); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("1000.00"), null); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("1000.00"), null, BigDecimal.ZERO); + + assertThat(loanCharge.getTaxAmount()).isEqualByComparingTo(new BigDecimal("160.000000")); + assertThat(loanCharge.getAmount()).isEqualByComparingTo(new BigDecimal("1000.00")); + } + + @Test + void populateDerivedFields_populatesTaxDetails_forEachTaxComponent() { + TaxComponent comp1 = mock(TaxComponent.class); + TaxComponent comp2 = mock(TaxComponent.class); + TaxGroup taxGroup = mock(TaxGroup.class); + + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + when(taxService.computeTax(any(), any(), any(), anyInt())) + .thenReturn(Map.of(comp1, new BigDecimal("10.000000"), comp2, new BigDecimal("5.000000"))); + + LoanChargeService service = buildService(taxService); + Charge charge = flatCharge(taxGroup); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("100.00"), null); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("100.00"), null, BigDecimal.ZERO); + + assertThat(loanCharge.getTaxDetails()).hasSize(2); + assertThat(loanCharge.getTaxDetails()).extracting(LoanChargeTaxDetails::getTaxComponent).containsExactlyInAnyOrder(comp1, comp2); + } + + @Test + void populateDerivedFields_setsAmountOutstanding_fromOriginalAmount() { + // base = 500, tax = 75 → amount stays 500, outstanding = 500 (tax is not added to amount) + TaxComponent component = mock(TaxComponent.class); + TaxGroup taxGroup = mock(TaxGroup.class); + + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + when(taxService.computeTax(any(), any(), any(), anyInt())).thenReturn(Map.of(component, new BigDecimal("75.000000"))); + + LoanChargeService service = buildService(taxService); + Charge charge = flatCharge(taxGroup); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("500.00"), null); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("500.00"), null, BigDecimal.ZERO); + + assertThat(loanCharge.getAmountOutstanding()).isEqualByComparingTo(new BigDecimal("500.00")); + } + + @Test + void populateDerivedFields_doesNotMutateCharge_whenComputedTaxIsZero() { + TaxGroup taxGroup = mock(TaxGroup.class); + + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + when(taxService.computeTax(any(), any(), any(), anyInt())).thenReturn(Collections.emptyMap()); + + LoanChargeService service = buildService(taxService); + Charge charge = flatCharge(taxGroup); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("200.00"), null); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("200.00"), null, BigDecimal.ZERO); + + assertThat(loanCharge.getTaxAmount()).isZero(); + assertThat(loanCharge.getAmount()).isEqualByComparingTo(new BigDecimal("200.00")); + } + + @Test + void populateDerivedFields_usesSubmittedOnDate_asEffectiveDateForTax() { + LocalDate submittedOn = LocalDate.of(2026, 1, 15); + TaxComponent component = mock(TaxComponent.class); + TaxGroup taxGroup = mock(TaxGroup.class); + + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + when(taxService.computeTax(any(), any(), any(), anyInt())).thenReturn(Map.of(component, new BigDecimal("20.000000"))); + + LoanChargeService service = buildService(taxService); + Charge charge = flatCharge(taxGroup); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("200.00"), submittedOn); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("200.00"), null, BigDecimal.ZERO); + + org.mockito.Mockito.verify(taxService).computeTax(taxGroup, new BigDecimal("200.00"), submittedOn, 6); + } + + @Test + void populateDerivedFields_usesBusinessDate_whenSubmittedOnDateIsNull() { + TaxComponent component = mock(TaxComponent.class); + TaxGroup taxGroup = mock(TaxGroup.class); + + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + when(taxService.computeTax(any(), any(), any(), anyInt())).thenReturn(Map.of(component, new BigDecimal("10.000000"))); + + LoanChargeService service = buildService(taxService); + Charge charge = flatCharge(taxGroup); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("100.00"), null /* no submittedOnDate */); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("100.00"), null, BigDecimal.ZERO); + + org.mockito.Mockito.verify(taxService).computeTax(taxGroup, new BigDecimal("100.00"), BUSINESS_DATE, 6); + } + + @Test + void populateDerivedFields_clearsPreviousTaxDetails_onReapplication() { + TaxComponent comp1 = mock(TaxComponent.class); + TaxComponent comp2 = mock(TaxComponent.class); + TaxGroup taxGroup = mock(TaxGroup.class); + + ChargeTaxApplicationService taxService = mock(ChargeTaxApplicationService.class); + when(taxService.computeTax(any(), any(), any(), anyInt())).thenReturn( + Map.of(comp1, new BigDecimal("10.000000"), comp2, new BigDecimal("5.000000")), Map.of(comp1, new BigDecimal("20.000000"))); + + LoanChargeService service = buildService(taxService); + Charge charge = flatCharge(taxGroup); + LoanCharge loanCharge = loanCharge(charge, new BigDecimal("100.00"), null); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("100.00"), null, BigDecimal.ZERO); + assertThat(loanCharge.getTaxDetails()).hasSize(2); + + loanCharge.setAmount(new BigDecimal("100.00")); + loanCharge.setAmountOutstanding(new BigDecimal("100.00")); + + service.populateDerivedFields(loanCharge, BigDecimal.ZERO, new BigDecimal("100.00"), null, BigDecimal.ZERO); + assertThat(loanCharge.getTaxDetails()).hasSize(1); + assertThat(loanCharge.getTaxDetails().get(0).getTaxComponent()).isEqualTo(comp1); + } + + private Charge flatCharge(TaxGroup taxGroup) { + Charge charge = mock(Charge.class); + when(charge.getTaxGroup()).thenReturn(taxGroup); + return charge; + } + + private LoanCharge loanCharge(Charge charge, BigDecimal amount, LocalDate submittedOnDate) { + LoanCharge lc = new LoanCharge(); + lc.setCharge(charge); + lc.setAmount(amount); + lc.setAmountOutstanding(amount); + lc.setChargeCalculation(ChargeCalculationType.FLAT.getValue()); + lc.setChargeTime(ChargeTimeType.SPECIFIED_DUE_DATE.getValue()); + lc.setSubmittedOnDate(submittedOnDate); + lc.setAmountOrPercentage(amount); + return lc; + } +} diff --git a/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml b/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml index 3b3a6142c0f..3c07e451e41 100644 --- a/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml +++ b/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml @@ -103,6 +103,7 @@ org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory org.apache.fineract.portfolio.loanaccount.domain.LoanCharge org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy + org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/ChargeTaxPaymentDTO.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/ChargeTaxPaymentDTO.java new file mode 100644 index 00000000000..7dee6d62f31 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/ChargeTaxPaymentDTO.java @@ -0,0 +1,33 @@ +/** + * 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.accounting.journalentry.data; + +import java.math.BigDecimal; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class ChargeTaxPaymentDTO { + + private final Long loanChargeId; + private final Long creditAccountId; + private final BigDecimal amount; + private final boolean penalty; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanTransactionDTO.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanTransactionDTO.java index 07defb666bd..16fcad69648 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanTransactionDTO.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanTransactionDTO.java @@ -20,6 +20,7 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -65,4 +66,8 @@ public class LoanTransactionDTO { private final BigDecimal principalPaid; private final BigDecimal feePaid; private final BigDecimal penaltyPaid; + + /** Used by accounting processors to split the fee income credit into net income + tax liability entries */ + @Setter + private List chargeTaxPayments = new ArrayList<>(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 21e81186b53..4e8ea5e973e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -41,6 +41,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ChargeTaxPaymentDTO; import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; import org.apache.fineract.accounting.journalentry.data.LoanDTO; @@ -71,6 +72,7 @@ import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; +import org.apache.fineract.portfolio.loanaccount.data.ChargeTaxDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; @@ -134,6 +136,7 @@ public LoanDTO populateLoanDtoFromDTO( final List feePaymentDetails = new ArrayList<>(); final List penaltyPaymentDetails = new ArrayList<>(); + final List chargeTaxPayments = new ArrayList<>(); // extract charge payment details (if exists) if (loanTxnDto.getLoanChargesPaid() != null) { List loanChargesPaidData = loanTxnDto.getLoanChargesPaid(); @@ -148,6 +151,10 @@ public LoanDTO populateLoanDtoFromDTO( } else { feePaymentDetails.add(chargePaymentDTO); } + for (ChargeTaxDetailDTO taxDetail : loanChargePaid.getTaxDetails()) { + chargeTaxPayments.add( + new ChargeTaxPaymentDTO(loanChargeId, taxDetail.getCreditAccountId(), taxDetail.getAmount(), isPenalty)); + } } } @@ -167,6 +174,7 @@ public LoanDTO populateLoanDtoFromDTO( feePaid, penaltyPaid); transaction.setLoanToLoanTransfer(loanTxnDto.isLoanToLoanTransfer()); + transaction.setChargeTaxPayments(chargeTaxPayments); newLoanTransactions.add(transaction); } @@ -879,6 +887,20 @@ public void createCreditJournalEntryForLoan(final Office office, final String cu createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); } + public void createCreditJournalEntryForLoanByGLAccountId(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Long glAccountId) { + final GLAccount account = glAccountRepository.findById(glAccountId) + .orElseThrow(() -> new IllegalStateException("GL account not found for tax liability entry: " + glAccountId)); + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoanByGLAccountId(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Long glAccountId) { + final GLAccount account = glAccountRepository.findById(glAccountId) + .orElseThrow(() -> new IllegalStateException("GL account not found for tax liability debit entry: " + glAccountId)); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + private void createCreditJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { final boolean manualEntry = false; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java index 8216d59bd1e..07930adee31 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java @@ -33,6 +33,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.journalentry.data.AdvancedMappingtDTO; import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ChargeTaxPaymentDTO; import org.apache.fineract.accounting.journalentry.data.GLAccountBalanceHolder; import org.apache.fineract.accounting.journalentry.data.LoanDTO; import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; @@ -49,6 +50,7 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess private final AccountingProcessorHelper helper; private final JournalEntryWritePlatformService journalEntryWritePlatformService; + private final LoanCommonAccountingHelper loanCommonAccountingHelper; @Override public void createJournalEntriesForLoan(final LoanDTO loanDTO) { @@ -1688,8 +1690,8 @@ private void createJournalEntriesForLoanRepayments(final LoanDTO loanDTO, final AccrualAccountsForLoan.LOAN_PORTFOLIO.getValue(), paymentTypeId); accountMap.put(account, principalAmount); if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, principalAmount, AccrualAccountsForLoan.GOODWILL_CREDIT.getValue(), - debitAccountMapForGoodwillCredit, paymentTypeId); + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, principalAmount, + AccrualAccountsForLoan.GOODWILL_CREDIT.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } } @@ -1705,7 +1707,7 @@ private void createJournalEntriesForLoanRepayments(final LoanDTO loanDTO, final accountMap.put(account, interestAmount); } if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, interestAmount, + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, interestAmount, AccrualAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_INTEREST.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } @@ -1715,8 +1717,21 @@ private void createJournalEntriesForLoanRepayments(final LoanDTO loanDTO, final if (MathUtil.isGreaterThanZero(feesAmount)) { totalDebitAmount = totalDebitAmount.add(feesAmount); if (isIncomeFromFee) { - this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), - loanProductId, loanId, transactionId, transactionDate, feesAmount, loanTransactionDTO.getFeePayments()); + final List feeTaxPayments = loanCommonAccountingHelper.filterTaxPayments(loanTransactionDTO, false); + final BigDecimal feeTaxTotal = loanCommonAccountingHelper.sumTaxAmounts(feeTaxPayments); + if (feeTaxTotal.compareTo(BigDecimal.ZERO) > 0) { + final BigDecimal netFees = feesAmount.subtract(feeTaxTotal); + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, + AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), loanProductId, loanId, transactionId, transactionDate, + netFees, + loanCommonAccountingHelper.computeNetChargePayments(loanTransactionDTO.getFeePayments(), feeTaxPayments)); + loanCommonAccountingHelper.createTaxLiabilityCreditEntries(office, currencyCode, loanId, transactionId, transactionDate, + feeTaxPayments); + } else { + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, + AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), loanProductId, loanId, transactionId, transactionDate, + feesAmount, loanTransactionDTO.getFeePayments()); + } } else { final GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, AccrualAccountsForLoan.FEES_RECEIVABLE.getValue(), paymentTypeId); @@ -1728,8 +1743,9 @@ private void createJournalEntriesForLoanRepayments(final LoanDTO loanDTO, final } } if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, feesAmount, AccrualAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_FEES.getValue(), - debitAccountMapForGoodwillCredit, paymentTypeId); + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, feesAmount, + AccrualAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_FEES.getValue(), debitAccountMapForGoodwillCredit, + paymentTypeId); } } @@ -1757,7 +1773,7 @@ private void createJournalEntriesForLoanRepayments(final LoanDTO loanDTO, final } if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, penaltiesAmount, + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, penaltiesAmount, AccrualAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_PENALTY.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } @@ -1774,8 +1790,8 @@ private void createJournalEntriesForLoanRepayments(final LoanDTO loanDTO, final accountMap.put(account, overPaymentAmount); } if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, overPaymentAmount, AccrualAccountsForLoan.GOODWILL_CREDIT.getValue(), - debitAccountMapForGoodwillCredit, paymentTypeId); + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, overPaymentAmount, + AccrualAccountsForLoan.GOODWILL_CREDIT.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } } @@ -1930,27 +1946,6 @@ private void createJournalEntriesForLoanWriteOffs(final LoanDTO loanDTO, final L } } - private void populateDebitAccountEntry(Long loanProductId, BigDecimal transactionPartAmount, Integer debitAccountType, - Map accountMapForDebit, Long paymentTypeId) { - Integer accountDebit = returnExistingDebitAccountInMapMatchingGLAccount(loanProductId, paymentTypeId, debitAccountType, - accountMapForDebit); - if (accountMapForDebit.containsKey(accountDebit)) { - BigDecimal amount = accountMapForDebit.get(accountDebit).add(transactionPartAmount); - accountMapForDebit.put(accountDebit, amount); - } else { - accountMapForDebit.put(accountDebit, transactionPartAmount); - } - } - - private Integer returnExistingDebitAccountInMapMatchingGLAccount(Long loanProductId, Long paymentTypeId, Integer accountType, - Map accountMap) { - GLAccount glAccount = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accountType, paymentTypeId); - Integer accountEntry = accountMap.entrySet().stream().filter(account -> this.helper - .getLinkedGLAccountForLoanProduct(loanProductId, account.getKey(), paymentTypeId).getGlCode().equals(glAccount.getGlCode())) - .map(Map.Entry::getKey).findFirst().orElse(accountType); - return accountEntry; - } - /** * Create a single Debit to fund source and a single credit to "Income from Recovery" */ @@ -2015,26 +2010,46 @@ private void createJournalEntriesForAccruals(final LoanDTO loanDTO, final LoanTr } // create journal entries for the fees application if (MathUtil.isGreaterThanZero(feesAmount)) { - if (transactionType.isAccrualAdjustment()) { - this.helper.createJournalEntriesForLoanCharges(office, currencyCode, AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), - AccrualAccountsForLoan.FEES_RECEIVABLE.getValue(), loanProductId, loanId, transactionId, transactionDate, - feesAmount, loanTransactionDTO.getFeePayments()); + final List feeTaxPayments = loanCommonAccountingHelper.filterTaxPayments(loanTransactionDTO, false); + final BigDecimal feeTaxTotal = loanCommonAccountingHelper.sumTaxAmounts(feeTaxPayments); + if (feeTaxTotal.compareTo(BigDecimal.ZERO) > 0) { + loanCommonAccountingHelper.createAccrualChargeJournalEntriesWithTax(office, currencyCode, loanProductId, loanId, + transactionId, transactionDate, feesAmount, loanTransactionDTO.getFeePayments(), feeTaxPayments, + AccrualAccountsForLoan.FEES_RECEIVABLE.getValue(), AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), + transactionType.isAccrualAdjustment()); } else { - this.helper.createJournalEntriesForLoanCharges(office, currencyCode, AccrualAccountsForLoan.FEES_RECEIVABLE.getValue(), - AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), loanProductId, loanId, transactionId, transactionDate, - feesAmount, loanTransactionDTO.getFeePayments()); + if (transactionType.isAccrualAdjustment()) { + this.helper.createJournalEntriesForLoanCharges(office, currencyCode, AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), + AccrualAccountsForLoan.FEES_RECEIVABLE.getValue(), loanProductId, loanId, transactionId, transactionDate, + feesAmount, loanTransactionDTO.getFeePayments()); + } else { + this.helper.createJournalEntriesForLoanCharges(office, currencyCode, AccrualAccountsForLoan.FEES_RECEIVABLE.getValue(), + AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(), loanProductId, loanId, transactionId, transactionDate, + feesAmount, loanTransactionDTO.getFeePayments()); + } } } // create journal entries for the penalties application if (MathUtil.isGreaterThanZero(penaltiesAmount)) { - if (transactionType.isAccrualAdjustment()) { - this.helper.createJournalEntriesForLoanCharges(office, currencyCode, - AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), AccrualAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), - loanProductId, loanId, transactionId, transactionDate, penaltiesAmount, loanTransactionDTO.getPenaltyPayments()); + final List penaltyTaxPayments = loanCommonAccountingHelper.filterTaxPayments(loanTransactionDTO, true); + final BigDecimal penaltyTaxTotal = loanCommonAccountingHelper.sumTaxAmounts(penaltyTaxPayments); + if (penaltyTaxTotal.compareTo(BigDecimal.ZERO) > 0) { + loanCommonAccountingHelper.createAccrualChargeJournalEntriesWithTax(office, currencyCode, loanProductId, loanId, + transactionId, transactionDate, penaltiesAmount, loanTransactionDTO.getPenaltyPayments(), penaltyTaxPayments, + AccrualAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), + transactionType.isAccrualAdjustment()); } else { - this.helper.createJournalEntriesForLoanCharges(office, currencyCode, AccrualAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), - AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), loanProductId, loanId, transactionId, transactionDate, - penaltiesAmount, loanTransactionDTO.getPenaltyPayments()); + if (transactionType.isAccrualAdjustment()) { + this.helper.createJournalEntriesForLoanCharges(office, currencyCode, + AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), AccrualAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), + loanProductId, loanId, transactionId, transactionDate, penaltiesAmount, + loanTransactionDTO.getPenaltyPayments()); + } else { + this.helper.createJournalEntriesForLoanCharges(office, currencyCode, + AccrualAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), + loanProductId, loanId, transactionId, transactionDate, penaltiesAmount, + loanTransactionDTO.getPenaltyPayments()); + } } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java index 6081ab9e039..1face013f5d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java @@ -30,6 +30,7 @@ import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ChargeTaxPaymentDTO; import org.apache.fineract.accounting.journalentry.data.GLAccountBalanceHolder; import org.apache.fineract.accounting.journalentry.data.LoanDTO; import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; @@ -44,6 +45,7 @@ public class CashBasedAccountingProcessorForLoan implements AccountingProcessorF private final AccountingProcessorHelper helper; private final JournalEntryWritePlatformService journalEntryWritePlatformService; + private final LoanCommonAccountingHelper loanCommonAccountingHelper; @Override public void createJournalEntriesForLoan(final LoanDTO loanDTO) { @@ -199,15 +201,6 @@ private void populateCreditDebitMaps(Long loanProductId, BigDecimal transactionP glAccountBalanceHolder.addToDebit(accountDebit, transactionPartAmount); } - private Integer returnExistingDebitAccountInMapMatchingGLAccount(Long loanProductId, Long paymentTypeId, Integer accountType, - Map accountMap) { - GLAccount glAccount = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accountType, paymentTypeId); - Integer accountEntry = accountMap.entrySet().stream().filter(account -> this.helper - .getLinkedGLAccountForLoanProduct(loanProductId, account.getKey(), paymentTypeId).getGlCode().equals(glAccount.getGlCode())) - .map(Map.Entry::getKey).findFirst().orElse(accountType); - return accountEntry; - } - private void createJournalEntriesForChargeAdjustment(LoanDTO loanDTO, LoanTransactionDTO loanTransactionDTO, Office office) { final boolean isMarkedAsChargeOff = loanDTO.isMarkedAsChargeOff(); if (isMarkedAsChargeOff) { @@ -752,8 +745,8 @@ private void createJournalEntriesForLoanRepayments(LoanDTO loanDTO, LoanTransact this.helper.createCreditJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.LOAN_PORTFOLIO, loanProductId, paymentTypeId, loanId, transactionId, transactionDate, principalAmount); if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, principalAmount, CashAccountsForLoan.GOODWILL_CREDIT.getValue(), - debitAccountMapForGoodwillCredit, paymentTypeId); + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, principalAmount, + CashAccountsForLoan.GOODWILL_CREDIT.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } } @@ -762,7 +755,7 @@ private void createJournalEntriesForLoanRepayments(LoanDTO loanDTO, LoanTransact this.helper.createCreditJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.INTEREST_ON_LOANS, loanProductId, paymentTypeId, loanId, transactionId, transactionDate, interestAmount); if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, interestAmount, + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, interestAmount, CashAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_INTEREST.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } @@ -770,20 +763,44 @@ private void createJournalEntriesForLoanRepayments(LoanDTO loanDTO, LoanTransact if (feesAmount != null && feesAmount.compareTo(BigDecimal.ZERO) > 0) { totalDebitAmount = totalDebitAmount.add(feesAmount); - this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, CashAccountsForLoan.INCOME_FROM_FEES.getValue(), - loanProductId, loanId, transactionId, transactionDate, feesAmount, loanTransactionDTO.getFeePayments()); + final List feeTaxPayments = loanCommonAccountingHelper.filterTaxPayments(loanTransactionDTO, false); + final BigDecimal feeTaxTotal = loanCommonAccountingHelper.sumTaxAmounts(feeTaxPayments); + if (feeTaxTotal.compareTo(BigDecimal.ZERO) > 0) { + final BigDecimal netFees = feesAmount.subtract(feeTaxTotal); + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, CashAccountsForLoan.INCOME_FROM_FEES.getValue(), + loanProductId, loanId, transactionId, transactionDate, netFees, + loanCommonAccountingHelper.computeNetChargePayments(loanTransactionDTO.getFeePayments(), feeTaxPayments)); + loanCommonAccountingHelper.createTaxLiabilityCreditEntries(office, currencyCode, loanId, transactionId, transactionDate, + feeTaxPayments); + } else { + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, CashAccountsForLoan.INCOME_FROM_FEES.getValue(), + loanProductId, loanId, transactionId, transactionDate, feesAmount, loanTransactionDTO.getFeePayments()); + } if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, feesAmount, CashAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_FEES.getValue(), - debitAccountMapForGoodwillCredit, paymentTypeId); + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, feesAmount, + CashAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_FEES.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } } if (penaltiesAmount != null && penaltiesAmount.compareTo(BigDecimal.ZERO) > 0) { totalDebitAmount = totalDebitAmount.add(penaltiesAmount); - this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), - loanProductId, loanId, transactionId, transactionDate, penaltiesAmount, loanTransactionDTO.getPenaltyPayments()); + final List penaltyTaxPayments = loanCommonAccountingHelper.filterTaxPayments(loanTransactionDTO, true); + final BigDecimal penaltyTaxTotal = loanCommonAccountingHelper.sumTaxAmounts(penaltyTaxPayments); + if (penaltyTaxTotal.compareTo(BigDecimal.ZERO) > 0) { + final BigDecimal netPenalties = penaltiesAmount.subtract(penaltyTaxTotal); + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, + CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), loanProductId, loanId, transactionId, transactionDate, + netPenalties, + loanCommonAccountingHelper.computeNetChargePayments(loanTransactionDTO.getPenaltyPayments(), penaltyTaxPayments)); + loanCommonAccountingHelper.createTaxLiabilityCreditEntries(office, currencyCode, loanId, transactionId, transactionDate, + penaltyTaxPayments); + } else { + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, + CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue(), loanProductId, loanId, transactionId, transactionDate, + penaltiesAmount, loanTransactionDTO.getPenaltyPayments()); + } if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, penaltiesAmount, + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, penaltiesAmount, CashAccountsForLoan.INCOME_FROM_GOODWILL_CREDIT_PENALTY.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } @@ -794,8 +811,8 @@ private void createJournalEntriesForLoanRepayments(LoanDTO loanDTO, LoanTransact this.helper.createCreditJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.OVERPAYMENT, loanProductId, paymentTypeId, loanId, transactionId, transactionDate, overPaymentAmount); if (loanTransactionDTO.getTransactionType().isGoodwillCredit()) { - populateDebitAccountEntry(loanProductId, overPaymentAmount, CashAccountsForLoan.GOODWILL_CREDIT.getValue(), - debitAccountMapForGoodwillCredit, paymentTypeId); + loanCommonAccountingHelper.populateDebitAccountEntry(loanProductId, overPaymentAmount, + CashAccountsForLoan.GOODWILL_CREDIT.getValue(), debitAccountMapForGoodwillCredit, paymentTypeId); } } @@ -832,18 +849,6 @@ private void createJournalEntriesForLoanRepayments(LoanDTO loanDTO, LoanTransact } } - private void populateDebitAccountEntry(Long loanProductId, BigDecimal transactionPartAmount, Integer debitAccountType, - Map accountMapForDebit, Long paymentTypeId) { - Integer accountDebit = returnExistingDebitAccountInMapMatchingGLAccount(loanProductId, paymentTypeId, debitAccountType, - accountMapForDebit); - if (accountMapForDebit.containsKey(accountDebit)) { - BigDecimal amount = accountMapForDebit.get(accountDebit).add(transactionPartAmount); - accountMapForDebit.put(accountDebit, amount); - } else { - accountMapForDebit.put(accountDebit, transactionPartAmount); - } - } - /** * Create a single Debit to fund source and a single credit to "Income from Recovery" */ @@ -1002,4 +1007,5 @@ private void createJournalEntriesForRefundForActiveLoan(LoanDTO loanDTO, LoanTra this.helper.createCreditJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, paymentTypeId, loanId, transactionId, transactionDate, totalDebitAmount); } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index 4558ba09221..5289f1be722 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -19,6 +19,7 @@ package org.apache.fineract.accounting.journalentry.service; import java.math.BigDecimal; +import java.math.MathContext; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; @@ -84,12 +85,14 @@ import org.apache.fineract.investor.exception.ExternalAssetOwnerNotFoundException; import org.apache.fineract.investor.service.AccountingService; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.organisation.monetary.domain.OrganisationCurrencyRepositoryWrapper; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO; import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; +import org.apache.fineract.portfolio.loanaccount.data.ChargeTaxDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; import org.apache.fineract.portfolio.loanaccount.domain.AmortizationType; import org.apache.fineract.portfolio.loanaccount.domain.Loan; @@ -97,6 +100,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy; +import org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; @@ -957,14 +961,30 @@ private AccountingBridgeLoanTransactionDTO convertToAccountingBridgeTransaction( // Populate loanChargesPaid from the transaction if (!loanTransaction.getLoanChargesPaid().isEmpty()) { List loanChargesPaidData = new ArrayList<>(); + final MathContext mc = MoneyHelper.getMathContext(); for (final LoanChargePaidBy chargePaidBy : loanTransaction.getLoanChargesPaid()) { + final LoanCharge lc = chargePaidBy.getLoanCharge(); final LoanChargePaidByDTO loanChargePaidData = new LoanChargePaidByDTO(); - loanChargePaidData.setChargeId(chargePaidBy.getLoanCharge().getCharge().getId()); - loanChargePaidData.setIsPenalty(chargePaidBy.getLoanCharge().isPenaltyCharge()); - loanChargePaidData.setLoanChargeId(chargePaidBy.getLoanCharge().getId()); + loanChargePaidData.setChargeId(lc.getCharge().getId()); + loanChargePaidData.setIsPenalty(lc.isPenaltyCharge()); + loanChargePaidData.setLoanChargeId(lc.getId()); loanChargePaidData.setAmount(chargePaidBy.getAmount()); loanChargePaidData.setInstallmentNumber(chargePaidBy.getInstallmentNumber()); + // Pro-rate each TaxComponent's tax proportionally to the paid amount + final BigDecimal chargeAmount = lc.getAmount(); + final BigDecimal paidAmount = chargePaidBy.getAmount(); + if (chargeAmount != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0 && !lc.getTaxDetails().isEmpty()) { + final List taxDetails = new ArrayList<>(); + for (LoanChargeTaxDetails taxDetail : lc.getTaxDetails()) { + if (taxDetail.getTaxComponent().getCreditAccount() != null) { + final BigDecimal proRatedTax = taxDetail.getAmount().multiply(paidAmount, mc).divide(chargeAmount, mc); + taxDetails.add(new ChargeTaxDetailDTO(taxDetail.getTaxComponent().getCreditAccount().getId(), proRatedTax)); + } + } + loanChargePaidData.setTaxDetails(taxDetails); + } + loanChargesPaidData.add(loanChargePaidData); } transactionDTO.setLoanChargesPaid(loanChargesPaidData); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LoanCommonAccountingHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LoanCommonAccountingHelper.java new file mode 100644 index 00000000000..282d84cc455 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LoanCommonAccountingHelper.java @@ -0,0 +1,122 @@ +/** + * 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.accounting.journalentry.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ChargeTaxPaymentDTO; +import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; +import org.apache.fineract.organisation.office.domain.Office; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoanCommonAccountingHelper { + + private final AccountingProcessorHelper helper; + + public List filterTaxPayments(final LoanTransactionDTO txn, final boolean penalty) { + return txn.getChargeTaxPayments().stream().filter(t -> t.isPenalty() == penalty).collect(Collectors.toList()); + } + + public BigDecimal sumTaxAmounts(final List taxPayments) { + return taxPayments.stream().map(ChargeTaxPaymentDTO::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public List computeNetChargePayments(final List chargePayments, + final List taxPayments) { + final Map taxByChargeId = taxPayments.stream().collect(Collectors.groupingBy(ChargeTaxPaymentDTO::getLoanChargeId, + Collectors.reducing(BigDecimal.ZERO, ChargeTaxPaymentDTO::getAmount, BigDecimal::add))); + final List result = new ArrayList<>(); + for (ChargePaymentDTO cp : chargePayments) { + final BigDecimal tax = taxByChargeId.getOrDefault(cp.getLoanChargeId(), BigDecimal.ZERO); + result.add(new ChargePaymentDTO(cp.getChargeId(), cp.getAmount().subtract(tax), cp.getLoanChargeId())); + } + return result; + } + + public void createTaxLiabilityCreditEntries(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final List taxPayments) { + final Map taxByAccount = taxPayments.stream() + .collect(Collectors.groupingBy(ChargeTaxPaymentDTO::getCreditAccountId, + Collectors.reducing(BigDecimal.ZERO, ChargeTaxPaymentDTO::getAmount, BigDecimal::add))); + for (Map.Entry entry : taxByAccount.entrySet()) { + this.helper.createCreditJournalEntryForLoanByGLAccountId(office, currencyCode, loanId, transactionId, transactionDate, + entry.getValue(), entry.getKey()); + } + } + + public void createTaxLiabilityDebitEntries(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final List taxPayments) { + final Map taxByAccount = taxPayments.stream() + .collect(Collectors.groupingBy(ChargeTaxPaymentDTO::getCreditAccountId, + Collectors.reducing(BigDecimal.ZERO, ChargeTaxPaymentDTO::getAmount, BigDecimal::add))); + for (Map.Entry entry : taxByAccount.entrySet()) { + this.helper.createDebitJournalEntryForLoanByGLAccountId(office, currencyCode, loanId, transactionId, transactionDate, + entry.getValue(), entry.getKey()); + } + } + + public void createAccrualChargeJournalEntriesWithTax(final Office office, final String currencyCode, final Long loanProductId, + final Long loanId, final String transactionId, final LocalDate transactionDate, final BigDecimal grossAmount, + final List chargePayments, final List taxPayments, final int receivableAccountType, + final int incomeAccountType, final boolean isAccrualAdjustment) { + final BigDecimal netAmount = grossAmount.subtract(sumTaxAmounts(taxPayments)); + final List netPayments = computeNetChargePayments(chargePayments, taxPayments); + if (isAccrualAdjustment) { + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, receivableAccountType, loanProductId, loanId, + transactionId, transactionDate, grossAmount, chargePayments); + this.helper.createDebitJournalEntryForLoanCharges(office, currencyCode, incomeAccountType, loanProductId, loanId, transactionId, + transactionDate, netAmount, netPayments); + createTaxLiabilityDebitEntries(office, currencyCode, loanId, transactionId, transactionDate, taxPayments); + } else { + this.helper.createDebitJournalEntryForLoanCharges(office, currencyCode, receivableAccountType, loanProductId, loanId, + transactionId, transactionDate, grossAmount, chargePayments); + this.helper.createCreditJournalEntryForLoanCharges(office, currencyCode, incomeAccountType, loanProductId, loanId, + transactionId, transactionDate, netAmount, netPayments); + createTaxLiabilityCreditEntries(office, currencyCode, loanId, transactionId, transactionDate, taxPayments); + } + } + + public void populateDebitAccountEntry(final Long loanProductId, final BigDecimal transactionPartAmount, final Integer debitAccountType, + final Map accountMapForDebit, final Long paymentTypeId) { + final Integer accountDebit = returnExistingDebitAccountInMapMatchingGLAccount(loanProductId, paymentTypeId, debitAccountType, + accountMapForDebit); + if (accountMapForDebit.containsKey(accountDebit)) { + accountMapForDebit.put(accountDebit, accountMapForDebit.get(accountDebit).add(transactionPartAmount)); + } else { + accountMapForDebit.put(accountDebit, transactionPartAmount); + } + } + + public Integer returnExistingDebitAccountInMapMatchingGLAccount(final Long loanProductId, final Long paymentTypeId, + final Integer accountType, final Map accountMap) { + final GLAccount glAccount = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accountType, paymentTypeId); + return accountMap.entrySet().stream().filter(account -> this.helper + .getLinkedGLAccountForLoanProduct(loanProductId, account.getKey(), paymentTypeId).getGlCode().equals(glAccount.getGlCode())) + .map(Map.Entry::getKey).findFirst().orElse(accountType); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index 68b0880bde7..442d330c2f8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -183,6 +183,7 @@ import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; import org.apache.fineract.portfolio.savings.service.GSIMReadPlatformService; +import org.apache.fineract.portfolio.tax.service.ChargeTaxApplicationService; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; @@ -508,9 +509,10 @@ public LoanDisbursementService loanDisbursementService(LoanChargeValidator loanC public LoanChargeService loanChargeService(final LoanChargeValidator loanChargeValidator, final LoanTransactionProcessingService loanTransactionProcessingService, final LoanLifecycleStateMachine loanLifecycleStateMachine, final LoanBalanceService loanBalanceService, - final LoanScheduleGeneratorService loanScheduleGeneratorService) { + final LoanScheduleGeneratorService loanScheduleGeneratorService, + final ChargeTaxApplicationService chargeTaxApplicationService) { return new LoanChargeService(loanChargeValidator, loanTransactionProcessingService, loanLifecycleStateMachine, loanBalanceService, - loanScheduleGeneratorService); + loanScheduleGeneratorService, chargeTaxApplicationService); } @Bean diff --git a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml index f9ebfb806ea..3c774696b2e 100644 --- a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml +++ b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml @@ -119,6 +119,7 @@ org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory org.apache.fineract.portfolio.loanaccount.domain.LoanCharge org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy + org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java index f42fe545bd7..e62de324f19 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java @@ -104,6 +104,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionProcessingService; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; +import org.apache.fineract.portfolio.tax.service.ChargeTaxApplicationService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -134,7 +135,7 @@ public class LoanAccountDelinquencyRangeEventSerializerTest { private final LoanChargeService loanChargeService = new LoanChargeService(mock(LoanChargeValidator.class), mock(LoanTransactionProcessingService.class), mock(LoanLifecycleStateMachine.class), mock(LoanBalanceService.class), - mock(LoanScheduleGeneratorService.class)); + mock(LoanScheduleGeneratorService.class), mock(ChargeTaxApplicationService.class)); private MockedStatic moneyHelper = Mockito.mockStatic(MoneyHelper.class); diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationService.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationService.java new file mode 100644 index 00000000000..ca11b2189b9 --- /dev/null +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationService.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tax.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Map; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxGroup; + +public interface ChargeTaxApplicationService { + + Map computeTax(TaxGroup taxGroup, BigDecimal baseAmount, LocalDate effectiveDate, int scale); +} diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceImpl.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceImpl.java new file mode 100644 index 00000000000..26264c53d9e --- /dev/null +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceImpl.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tax.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collections; +import java.util.Map; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxGroup; +import org.springframework.stereotype.Service; + +@Service +public class ChargeTaxApplicationServiceImpl implements ChargeTaxApplicationService { + + @Override + public Map computeTax(final TaxGroup taxGroup, final BigDecimal baseAmount, final LocalDate effectiveDate, + final int scale) { + if (taxGroup == null || baseAmount == null || baseAmount.compareTo(BigDecimal.ZERO) == 0) { + return Collections.emptyMap(); + } + return TaxUtils.splitTax(baseAmount, effectiveDate, taxGroup.getTaxGroupMappings(), scale); + } +} diff --git a/fineract-tax/src/test/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceTest.java b/fineract-tax/src/test/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceTest.java new file mode 100644 index 00000000000..8e313c5cc32 --- /dev/null +++ b/fineract-tax/src/test/java/org/apache/fineract/portfolio/tax/service/ChargeTaxApplicationServiceTest.java @@ -0,0 +1,246 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tax.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxGroup; +import org.apache.fineract.portfolio.tax.domain.TaxGroupMappings; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Unit tests for {@link ChargeTaxApplicationServiceImpl}. + * + * Each test stubs only the collaborators needed (TaxGroup, TaxGroupMappings, TaxComponent) so no Spring context or + * database is required. + */ +class ChargeTaxApplicationServiceTest { + + private static MockedStatic moneyHelperMock; + + private final ChargeTaxApplicationService service = new ChargeTaxApplicationServiceImpl(); + + private final LocalDate actualDate = LocalDate.now(ZoneId.systemDefault()); + + @BeforeAll + static void setUpMoneyHelper() { + moneyHelperMock = mockStatic(MoneyHelper.class); + moneyHelperMock.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.HALF_EVEN); + } + + @AfterAll + static void tearDownMoneyHelper() { + moneyHelperMock.close(); + } + + @Test + void computeTax_returnsEmptyMap_whenTaxGroupIsNull() { + Map result = service.computeTax(null, new BigDecimal("100.00"), actualDate, 6); + + assertThat(result).isEmpty(); + } + + @Test + void computeTax_returnsEmptyMap_whenBaseAmountIsNull() { + TaxGroup taxGroup = mock(TaxGroup.class); + + Map result = service.computeTax(taxGroup, null, actualDate, 6); + + assertThat(result).isEmpty(); + } + + @Test + void computeTax_returnsEmptyMap_whenBaseAmountIsZero() { + TaxGroup taxGroup = mock(TaxGroup.class); + + Map result = service.computeTax(taxGroup, BigDecimal.ZERO, actualDate, 6); + + assertThat(result).isEmpty(); + } + + @Test + void computeTax_calculatesTaxCorrectly_forSingleComponent() { + // base = 1000, rate = 16 % → tax = 160 + LocalDate effectiveDate = LocalDate.of(2026, 1, 1); + TaxComponent component = taxComponentWithRate(new BigDecimal("16"), effectiveDate.minusDays(1)); + TaxGroup taxGroup = taxGroupWith(component, effectiveDate.minusDays(1), null); + + Map result = service.computeTax(taxGroup, new BigDecimal("1000.00"), effectiveDate, 6); + + assertThat(result).hasSize(1); + BigDecimal tax = result.get(component); + assertThat(tax).isNotNull(); + assertThat(tax.setScale(2, RoundingMode.HALF_EVEN)).isEqualByComparingTo(new BigDecimal("160.00")); + } + + @Test + void computeTax_calculatesTaxCorrectly_forSmallAmount() { + // base = 50, rate = 10 % → tax = 5 + LocalDate effectiveDate = LocalDate.of(2026, 3, 1); + TaxComponent component = taxComponentWithRate(new BigDecimal("10"), effectiveDate.minusDays(1)); + TaxGroup taxGroup = taxGroupWith(component, effectiveDate.minusDays(1), null); + + Map result = service.computeTax(taxGroup, new BigDecimal("50.00"), effectiveDate, 6); + + assertThat(result).hasSize(1); + assertThat(result.get(component).setScale(2, RoundingMode.HALF_EVEN)).isEqualByComparingTo(new BigDecimal("5.00")); + } + + @Test + void computeTax_returnsTaxForEachActiveComponent_whenMultipleComponentsConfigured() { + // base = 200, component1 = 10 % → 20, component2 = 5 % → 10 + LocalDate effectiveDate = LocalDate.of(2026, 4, 1); + TaxComponent comp1 = taxComponentWithRate(new BigDecimal("10"), effectiveDate.minusDays(10)); + TaxComponent comp2 = taxComponentWithRate(new BigDecimal("5"), effectiveDate.minusDays(10)); + + Set mappings = new HashSet<>(); + mappings.add(activeMappingFor(comp1, effectiveDate.minusDays(10), null)); + mappings.add(activeMappingFor(comp2, effectiveDate.minusDays(10), null)); + + TaxGroup taxGroup = mock(TaxGroup.class); + when(taxGroup.getTaxGroupMappings()).thenReturn(mappings); + + Map result = service.computeTax(taxGroup, new BigDecimal("200.00"), effectiveDate, 6); + + assertThat(result).hasSize(2); + assertThat(result.get(comp1).setScale(2, RoundingMode.HALF_EVEN)).isEqualByComparingTo(new BigDecimal("20.00")); + assertThat(result.get(comp2).setScale(2, RoundingMode.HALF_EVEN)).isEqualByComparingTo(new BigDecimal("10.00")); + } + + @Test + void computeTax_totalTaxSumMatchesExpected_forMultipleComponents() { + LocalDate effectiveDate = LocalDate.of(2026, 4, 1); + TaxComponent comp1 = taxComponentWithRate(new BigDecimal("10"), effectiveDate.minusDays(10)); + TaxComponent comp2 = taxComponentWithRate(new BigDecimal("5"), effectiveDate.minusDays(10)); + + Set mappings = new HashSet<>(); + mappings.add(activeMappingFor(comp1, effectiveDate.minusDays(10), null)); + mappings.add(activeMappingFor(comp2, effectiveDate.minusDays(10), null)); + + TaxGroup taxGroup = mock(TaxGroup.class); + when(taxGroup.getTaxGroupMappings()).thenReturn(mappings); + + Map result = service.computeTax(taxGroup, new BigDecimal("200.00"), effectiveDate, 6); + + BigDecimal total = TaxUtils.totalTaxAmount(result); + assertThat(total.setScale(2, RoundingMode.HALF_EVEN)).isEqualByComparingTo(new BigDecimal("30.00")); + } + + @Test + void computeTax_excludesExpiredComponent_whenMappingEndDateIsPast() { + // Component mapping expired before effectiveDate → should NOT appear in result + LocalDate effectiveDate = LocalDate.of(2026, 4, 10); + TaxComponent component = taxComponentWithRate(new BigDecimal("15"), effectiveDate.minusDays(30)); + // mapping ended 5 days ago + TaxGroupMappings expiredMapping = activeMappingFor(component, effectiveDate.minusDays(30), effectiveDate.minusDays(5)); + + TaxGroup taxGroup = mock(TaxGroup.class); + when(taxGroup.getTaxGroupMappings()).thenReturn(Set.of(expiredMapping)); + + Map result = service.computeTax(taxGroup, new BigDecimal("500.00"), effectiveDate, 6); + + assertThat(result).isEmpty(); + } + + @Test + void computeTax_includesActiveAndExcludesExpired_whenMixedMappings() { + LocalDate effectiveDate = LocalDate.of(2026, 4, 10); + + TaxComponent activeComp = taxComponentWithRate(new BigDecimal("10"), effectiveDate.minusDays(20)); + TaxComponent expiredComp = taxComponentWithRate(new BigDecimal("5"), effectiveDate.minusDays(20)); + + TaxGroupMappings activeMapping = activeMappingFor(activeComp, effectiveDate.minusDays(20), null); + TaxGroupMappings expiredMapping = activeMappingFor(expiredComp, effectiveDate.minusDays(20), effectiveDate.minusDays(1)); + + TaxGroup taxGroup = mock(TaxGroup.class); + when(taxGroup.getTaxGroupMappings()).thenReturn(Set.of(activeMapping, expiredMapping)); + + Map result = service.computeTax(taxGroup, new BigDecimal("100.00"), effectiveDate, 6); + + assertThat(result).hasSize(1).containsKey(activeComp); + assertThat(result.get(activeComp).setScale(2, RoundingMode.HALF_EVEN)).isEqualByComparingTo(new BigDecimal("10.00")); + } + + @Test + void computeTax_returnsEmptyMap_whenMappingHasNotStartedYet() { + // Component starts tomorrow → not applicable today + LocalDate effectiveDate = LocalDate.of(2026, 4, 10); + TaxComponent component = taxComponentWithRate(new BigDecimal("16"), effectiveDate.plusDays(1)); + TaxGroupMappings futureMapping = activeMappingFor(component, effectiveDate.plusDays(1), null); + + TaxGroup taxGroup = mock(TaxGroup.class); + when(taxGroup.getTaxGroupMappings()).thenReturn(Set.of(futureMapping)); + + Map result = service.computeTax(taxGroup, new BigDecimal("1000.00"), effectiveDate, 6); + + assertThat(result).isEmpty(); + } + + @Test + void computeTax_respectsRequestedScale() { + LocalDate effectiveDate = LocalDate.of(2026, 4, 1); + TaxComponent component = taxComponentWithRate(new BigDecimal("7"), effectiveDate.minusDays(1)); + TaxGroup taxGroup = taxGroupWith(component, effectiveDate.minusDays(1), null); + + Map result = service.computeTax(taxGroup, new BigDecimal("333.33"), effectiveDate, 2); + + assertThat(result.get(component).scale()).isEqualTo(2); + } + + private TaxComponent taxComponentWithRate(BigDecimal percentage, LocalDate startDate) { + TaxComponent component = mock(TaxComponent.class); + when(component.getApplicablePercentage(org.mockito.ArgumentMatchers.any(LocalDate.class))).thenReturn(percentage); + return component; + } + + private TaxGroupMappings activeMappingFor(TaxComponent component, LocalDate startDate, LocalDate endDate) { + TaxGroupMappings mapping = mock(TaxGroupMappings.class); + when(mapping.getTaxComponent()).thenReturn(component); + // occursOnDayFromAndUpToAndIncluding: after startDate AND (endDate == null OR not after endDate) + when(mapping.occursOnDayFromAndUpToAndIncluding(org.mockito.ArgumentMatchers.any(LocalDate.class))).thenAnswer(inv -> { + LocalDate target = inv.getArgument(0); + boolean afterStart = target.isAfter(startDate); + boolean beforeEnd = endDate == null || !target.isAfter(endDate); + return afterStart && beforeEnd; + }); + return mapping; + } + + private TaxGroup taxGroupWith(TaxComponent component, LocalDate startDate, LocalDate endDate) { + TaxGroupMappings mapping = activeMappingFor(component, startDate, endDate); + TaxGroup taxGroup = mock(TaxGroup.class); + when(taxGroup.getTaxGroupMappings()).thenReturn(Set.of(mapping)); + return taxGroup; + } +} diff --git a/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml b/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml index 18be331a22a..e7df592bfd2 100644 --- a/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml +++ b/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml @@ -103,6 +103,7 @@ org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory org.apache.fineract.portfolio.loanaccount.domain.LoanCharge org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy + org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeTaxIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeTaxIntegrationTest.java new file mode 100644 index 00000000000..71bf8d392dc --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeTaxIntegrationTest.java @@ -0,0 +1,554 @@ +/** + * 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 java.math.BigDecimal; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.GetChargesResponse; +import org.apache.fineract.client.models.GetLoansLoanIdChargesChargeIdResponse; +import org.apache.fineract.client.models.GetTaxesGroupResponse; +import org.apache.fineract.client.models.PostChargesResponse; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoansLoanIdChargesRequest; +import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse; +import org.apache.fineract.client.models.PostTaxesComponentsRequest; +import org.apache.fineract.client.models.PostTaxesComponentsResponse; +import org.apache.fineract.client.models.PostTaxesGroupRequest; +import org.apache.fineract.client.models.PostTaxesGroupResponse; +import org.apache.fineract.client.models.PostTaxesGroupTaxComponents; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.TaxComponentHelper; +import org.apache.fineract.integrationtests.common.TaxGroupHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.accounting.PeriodicAccrualAccountingHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType; +import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode; +import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@Slf4j +@ExtendWith(LoanTestLifecycleExtension.class) +public class LoanChargeTaxIntegrationTest extends BaseLoanIntegrationTest { + + private static final String DATE_FORMAT = "dd MMMM yyyy"; + private static final String LOAN_DATE = "01 January 2023"; + private static final String DUE_DATE = "15 January 2023"; + private static final String TAX_START_DATE = "01 January 2013"; + + // ----------------------------------------------------------------------- + // 1. TaxGroup setup helpers + // ----------------------------------------------------------------------- + + /** + * Creates a TaxComponent with the given percentage and a fixed start date in the past so it is always active during + * integration test runs. + */ + private PostTaxesComponentsResponse createTaxComponent(float percentage) { + PostTaxesComponentsRequest request = new PostTaxesComponentsRequest().name(Utils.uniqueRandomStringGenerator("TAX_COMP_", 6)) + .percentage(percentage).startDate(TAX_START_DATE).dateFormat(DATE_FORMAT).locale(LOCALE); + PostTaxesComponentsResponse response = TaxComponentHelper.createTaxComponent(request); + assertNotNull(response); + assertNotNull(response.getResourceId()); + return response; + } + + /** + * Wraps a list of already-created TaxComponent IDs into a TaxGroup. + */ + private PostTaxesGroupResponse createTaxGroup(Long... taxComponentIds) { + Set components = new HashSet<>(); + for (Long id : taxComponentIds) { + components.add(new PostTaxesGroupTaxComponents().taxComponentId(id).startDate(TAX_START_DATE)); + } + PostTaxesGroupRequest request = new PostTaxesGroupRequest().name(Utils.uniqueRandomStringGenerator("TAX_GRP_", 6)) + .taxComponents(components).dateFormat(DATE_FORMAT).locale(LOCALE); + PostTaxesGroupResponse response = TaxGroupHelper.createTaxGroup(request); + assertNotNull(response); + assertNotNull(response.getResourceId()); + return response; + } + + /** + * Creates a TaxComponent with the given percentage, linked to the provided GL account as credit account (tax + * liability). The credit account determines where the tax portion is posted in accounting. + */ + private PostTaxesComponentsResponse createTaxComponent(float percentage, Long creditAccountId) { + PostTaxesComponentsRequest request = new PostTaxesComponentsRequest().name(Utils.uniqueRandomStringGenerator("TAX_COMP_", 6)) + .percentage(percentage).startDate(TAX_START_DATE).dateFormat(DATE_FORMAT).locale(LOCALE).creditAccountId(creditAccountId) + .creditAccountType(2); + PostTaxesComponentsResponse response = TaxComponentHelper.createTaxComponent(request); + assertNotNull(response); + assertNotNull(response.getResourceId()); + return response; + } + + /** + * Creates a one-period, 30-day loan product using Cash-based accounting (accountingRule=2). Only the minimum set of + * GL accounts required for cash accounting is mapped. + */ + private PostLoanProductsRequest createCashBasedLoanProduct() { + return new PostLoanProductsRequest().name(Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_CASH_", 6)) + .shortName(Utils.uniqueRandomStringGenerator("", 4)).description("Cash-based loan product for tax tests") + .includeInBorrowerCycle(false).currencyCode("USD").digitsAfterDecimal(2).inMultiplesOf(0).installmentAmountInMultiplesOf(1) + .useBorrowerCycle(false).minPrincipal(100.0).principal(1000.0).maxPrincipal(100000.0).minNumberOfRepayments(1) + .numberOfRepayments(1).maxNumberOfRepayments(30).isLinkedToFloatingInterestRates(false).minInterestRatePerPeriod((double) 0) + .interestRatePerPeriod(0.0).maxInterestRatePerPeriod((double) 100).interestRateFrequencyType(2).repaymentEvery(30) + .repaymentFrequencyType(0L).amortizationType(1).interestType(0).isEqualAmortization(false).interestCalculationPeriodType(1) + .transactionProcessingStrategyCode( + LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY) + .daysInYearType(1).daysInMonthType(1).canDefineInstallmentAmount(true).graceOnArrearsAgeing(3).overdueDaysForNPA(179) + .accountMovesOutOfNPAOnlyOnArrearsCompletion(false).principalThresholdForLastInstallment(50) + .allowVariableInstallments(false).canUseForTopup(false).isInterestRecalculationEnabled(false).holdGuaranteeFunds(false) + .multiDisburseLoan(true).maxTrancheCount(10).outstandingLoanBalance(10000.0).charges(Collections.emptyList()) + .accountingRule(2) // Cash-based + .fundSourceAccountId(fundSource.getAccountID().longValue()) + .loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue()) + .transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue()) + .interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue()) + .incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue()) + .incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue()) + .incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue()) + .writeOffAccountId(writtenOffAccount.getAccountID().longValue()) + .overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue()).dateFormat(DATETIME_PATTERN).locale("en_GB") + .disallowExpectedDisbursements(true).allowApprovedDisbursedAmountsOverApplied(true).overAppliedCalculationType("percentage") + .overAppliedNumber(50); + } + + /** + * Creates a FLAT, SPECIFIED_DUE_DATE charge definition linked to the given {@code taxGroupId}. Passing {@code null} + * for {@code taxGroupId} creates a charge with no tax. + */ + private PostChargesResponse createFlatLoanCharge(double baseAmount, Long taxGroupId) { + ChargesHelper chargesHelper = new ChargesHelper(); + ChargeRequest request = new ChargeRequest().penalty(false).amount(baseAmount) + .chargeCalculationType(ChargeCalculationType.FLAT.getValue()).chargeTimeType(ChargeTimeType.SPECIFIED_DUE_DATE.getValue()) + .chargePaymentMode(ChargePaymentMode.REGULAR.getValue()).currencyCode("USD") + .name(Utils.uniqueRandomStringGenerator("CHARGE_", 6)).chargeAppliesTo(1).locale(LOCALE).active(true); + if (taxGroupId != null) { + request.taxGroupId(taxGroupId); + } + PostChargesResponse response = chargesHelper.createCharges(request); + assertNotNull(response); + assertNotNull(response.getResourceId()); + return response; + } + + // ----------------------------------------------------------------------- + // 2. Happy path – single tax component + // ----------------------------------------------------------------------- + + /** + * A loan charge with a 10 % TaxGroup applied to a flat charge of 100 USD must keep its amount at 100 USD. The 10 + * USD tax is stored separately as {@code taxAmount} and is not added to the charge amount. + * + *
+     *   base = 100, tax = 10 % → amount = 100, taxAmount = 10
+     * 
+ */ + @Test + public void testLoanChargeAmount_remainsUnchanged_whenTaxGroupIsConfigured() { + runAt(LOAN_DATE, () -> { + // Given – tax infrastructure + PostTaxesComponentsResponse taxComponent = createTaxComponent(10.0f); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + + // Given – charge definition with 10 % tax group, base = 100 + PostChargesResponse chargeResponse = createFlatLoanCharge(100.0, taxGroup.getResourceId()); + Long chargeDefinitionId = chargeResponse.getResourceId(); + + // Given – a disbursed loan + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = loanProductHelper.createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()) + .getResourceId(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId, BigDecimal.valueOf(1000.0), LOAN_DATE); + + // When – the taxed charge is added to the loan + PostLoansLoanIdChargesResponse addResult = loanTransactionHelper.addChargesForLoan(loanId, new PostLoansLoanIdChargesRequest() + .chargeId(chargeDefinitionId).amount(100.0).dueDate(DUE_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + assertNotNull(addResult); + Long loanChargeId = addResult.getResourceId(); + assertNotNull(loanChargeId); + + // Then – amount stays at 100; tax (10) is stored separately as taxAmount + GetLoansLoanIdChargesChargeIdResponse loanCharge = loanTransactionHelper.getLoanCharge(loanId, loanChargeId); + assertNotNull(loanCharge); + assertEquals(100.0, loanCharge.getAmount(), 0.01, "Charge amount must remain unchanged; tax stored separately as taxAmount=10"); + assertEquals(100.0, loanCharge.getAmountOutstanding(), 0.01, + "Outstanding amount must equal the original charge amount, not the charge+tax amount"); + }); + } + + // ----------------------------------------------------------------------- + // 3. Happy path – multiple tax components + // ----------------------------------------------------------------------- + + /** + * A TaxGroup with two components (10 % + 5 %) applied to a flat charge of 200 USD must keep the loan-charge amount + * at 200 USD. The taxes (20 + 10 = 30) are stored separately as {@code taxAmount}. + */ + @Test + public void testLoanChargeAmount_remainsUnchanged_multipleComponents() { + runAt(LOAN_DATE, () -> { + // Given – two tax components in the same group + PostTaxesComponentsResponse comp1 = createTaxComponent(10.0f); + PostTaxesComponentsResponse comp2 = createTaxComponent(5.0f); + PostTaxesGroupResponse taxGroup = createTaxGroup(comp1.getResourceId(), comp2.getResourceId()); + + PostChargesResponse chargeResponse = createFlatLoanCharge(200.0, taxGroup.getResourceId()); + + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = loanProductHelper.createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()) + .getResourceId(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId, BigDecimal.valueOf(1000.0), LOAN_DATE); + + // When + PostLoansLoanIdChargesResponse addResult = loanTransactionHelper.addChargesForLoan(loanId, new PostLoansLoanIdChargesRequest() + .chargeId(chargeResponse.getResourceId()).amount(200.0).dueDate(DUE_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + Long loanChargeId = addResult.getResourceId(); + + // Then – amount stays 200; taxes (20+10=30) stored separately as taxAmount + GetLoansLoanIdChargesChargeIdResponse loanCharge = loanTransactionHelper.getLoanCharge(loanId, loanChargeId); + assertEquals(200.0, loanCharge.getAmount(), 0.01, + "Charge amount must remain 200; taxes (20+10=30) stored separately as taxAmount"); + }); + } + + // ----------------------------------------------------------------------- + // 4. No TaxGroup – charge amount unchanged + // ----------------------------------------------------------------------- + + /** + * A charge without a TaxGroup must be added to the loan at its original base amount without any modification. + */ + @Test + public void testLoanChargeAmount_noTaxGroup_isNotModified() { + runAt(LOAN_DATE, () -> { + // Given – charge with NO tax group + PostChargesResponse chargeResponse = createFlatLoanCharge(100.0, null); + + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = loanProductHelper.createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()) + .getResourceId(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId, BigDecimal.valueOf(1000.0), LOAN_DATE); + + // When + PostLoansLoanIdChargesResponse addResult = loanTransactionHelper.addChargesForLoan(loanId, new PostLoansLoanIdChargesRequest() + .chargeId(chargeResponse.getResourceId()).amount(100.0).dueDate(DUE_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + Long loanChargeId = addResult.getResourceId(); + + // Then – amount must stay at 100 (no tax) + GetLoansLoanIdChargesChargeIdResponse loanCharge = loanTransactionHelper.getLoanCharge(loanId, loanChargeId); + assertEquals(100.0, loanCharge.getAmount(), 0.01, "Charge without tax group must keep original amount"); + }); + } + + // ----------------------------------------------------------------------- + // 5. Charge definition carries TaxGroup metadata + // ----------------------------------------------------------------------- + + /** + * After creating a charge definition that references a TaxGroup, retrieving the charge via GET /charges/{id} must + * return the charge with the correct {@code taxGroup} attribute populated. + */ + @Test + public void testChargeDefinition_hasTaxGroupPopulated() { + // Given – tax infrastructure (no loan needed for this test) + PostTaxesComponentsResponse taxComponent = createTaxComponent(16.0f); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + + // When – create charge linked to the tax group + PostChargesResponse chargeResponse = createFlatLoanCharge(50.0, taxGroup.getResourceId()); + + // Then – retrieve and verify taxGroup is set + ChargesHelper chargesHelper = new ChargesHelper(); + GetChargesResponse chargeData = chargesHelper.retrieveCharge(chargeResponse.getResourceId()); + assertNotNull(chargeData); + assertNotNull(chargeData.getTaxGroup(), "Charge must expose taxGroup in GET response"); + assertEquals(taxGroup.getResourceId(), chargeData.getTaxGroup().getId(), + "Charge taxGroup id must match the one used during creation"); + } + + // ----------------------------------------------------------------------- + // 6. TaxGroup retrievable with its components + // ----------------------------------------------------------------------- + + /** + * A TaxGroup created with one TaxComponent must be retrievable via GET /taxes/group/{id} and include the correct + * component in the mappings list. + */ + @Test + public void testTaxGroupRetrieval_includesExpectedComponent() { + // Given + PostTaxesComponentsResponse taxComponent = createTaxComponent(12.0f); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + + // When + GetTaxesGroupResponse retrieved = TaxGroupHelper.retrieveTaxGroup(taxGroup.getResourceId()); + + // Then + assertNotNull(retrieved); + assertEquals(taxGroup.getResourceId(), retrieved.getId()); + assertNotNull(retrieved.getTaxAssociations(), "TaxGroup must expose its component associations list"); + boolean componentPresent = retrieved.getTaxAssociations().stream() + .anyMatch(m -> taxComponent.getResourceId().equals(m.getTaxComponent().getId())); + assertEquals(true, componentPresent, "TaxGroup must contain the TaxComponent used during creation"); + } + + // ----------------------------------------------------------------------- + // 7. TaxGroup appears in retrieveAll list + // ----------------------------------------------------------------------- + + /** + * A newly created TaxGroup must be visible in the paginated list returned by GET /taxes/group. + */ + @Test + public void testTaxGroup_appearsInRetrieveAllList() { + // Given + PostTaxesComponentsResponse taxComponent = createTaxComponent(8.0f); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + + // When + List allGroups = TaxGroupHelper.retrieveAllTaxGroups(); + + // Then + assertNotNull(allGroups); + boolean found = allGroups.stream().anyMatch(g -> taxGroup.getResourceId().equals(g.getId())); + assertEquals(true, found, "Newly created TaxGroup must appear in the full tax group list"); + } + + // ----------------------------------------------------------------------- + // 8. Multiple loans – tax applied independently per loan charge + // ----------------------------------------------------------------------- + + /** + * Adding the same taxed charge definition to two different loans must result in independent charge records each + * keeping the original base amount. Verifies that per-loan charge state is isolated. + */ + @Test + public void testLoanChargeTax_appliedIndependentlyToEachLoan() { + runAt(LOAN_DATE, () -> { + // Given – shared charge definition with 10 % tax + PostTaxesComponentsResponse taxComponent = createTaxComponent(10.0f); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + PostChargesResponse chargeResponse = createFlatLoanCharge(100.0, taxGroup.getResourceId()); + Long chargeDefinitionId = chargeResponse.getResourceId(); + + // Given – two separate loans + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = loanProductHelper.createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()) + .getResourceId(); + + Long loanId1 = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId1, BigDecimal.valueOf(1000.0), LOAN_DATE); + + Long loanId2 = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId2, BigDecimal.valueOf(1000.0), LOAN_DATE); + + // When – the same charge is added to both loans + PostLoansLoanIdChargesResponse add1 = loanTransactionHelper.addChargesForLoan(loanId1, new PostLoansLoanIdChargesRequest() + .chargeId(chargeDefinitionId).amount(100.0).dueDate(DUE_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + PostLoansLoanIdChargesResponse add2 = loanTransactionHelper.addChargesForLoan(loanId2, new PostLoansLoanIdChargesRequest() + .chargeId(chargeDefinitionId).amount(100.0).dueDate(DUE_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + + // Then – both loan charges must independently show the original base amount (tax stored separately) + GetLoansLoanIdChargesChargeIdResponse charge1 = loanTransactionHelper.getLoanCharge(loanId1, add1.getResourceId()); + GetLoansLoanIdChargesChargeIdResponse charge2 = loanTransactionHelper.getLoanCharge(loanId2, add2.getResourceId()); + + assertEquals(100.0, charge1.getAmount(), 0.01, "Loan 1 charge amount must remain 100; tax stored separately"); + assertEquals(100.0, charge2.getAmount(), 0.01, "Loan 2 charge amount must remain 100; tax stored separately"); + }); + } + + // ----------------------------------------------------------------------- + // 10. Cash-based accounting: fee with tax splits income vs. tax liability + // ----------------------------------------------------------------------- + + /** + * Cash-based accounting: when a loan charge with a 10 % TaxGroup (base = 100 USD) is repaid, the accounting must + * split the fee income credit: + * + *
+     *   DR  Fund Source         1100.00
+     *   CR  Loan Portfolio      1000.00
+     *   CR  Income from Fees      90.00  (net = base − tax)
+     *   CR  Tax Liability         10.00  (TaxComponent.creditAccount)
+     * 
+ * + * The disbursement journal entries (DR Loan Portfolio / CR Fund Source) are also verified. + */ + @Test + public void testCashAccounting_journalEntries_feeWithTaxSplitsIncomeAndLiability() { + runAt(LOAN_DATE, () -> { + // Given – a liability account to receive the tax portion + Account taxLiabilityAccount = accountHelper.createLiabilityAccount("taxLiability_cash"); + + // Given – tax infrastructure linked to the tax liability account + PostTaxesComponentsResponse taxComponent = createTaxComponent(10.0f, taxLiabilityAccount.getAccountID().longValue()); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + + // Given – flat charge of 100 with 10 % tax, due on the loan start date + PostChargesResponse chargeResponse = createFlatLoanCharge(100.0, taxGroup.getResourceId()); + + // Given – a cash-based loan (principal = 1000, no interest) + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = loanProductHelper.createLoanProduct(createCashBasedLoanProduct() // + .multiDisburseLoan(false).disallowExpectedDisbursements(null) // + ).getResourceId(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId, BigDecimal.valueOf(1000.0), LOAN_DATE); + + // When – add the taxed charge (due on same date) and repay principal + fee in one transaction + loanTransactionHelper.addChargesForLoan(loanId, new PostLoansLoanIdChargesRequest().chargeId(chargeResponse.getResourceId()) + .amount(100.0).dueDate(LOAN_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + + addRepaymentForLoan(loanId, 1100.0, LOAN_DATE); + + // Then – verify journal entries: + // Disbursement: DR Loan Portfolio 1000 / CR Fund Source 1000 + // Repayment: DR Fund Source 1100 / CR Loan Portfolio 1000 / CR Income from Fees 90 / CR Tax Liability 10 + verifyJournalEntries(loanId, + // disbursement + debit(loansReceivableAccount, 1000.0), credit(fundSource, 1000.0), + // repayment + debit(fundSource, 1100.0), credit(loansReceivableAccount, 1000.0), credit(feeIncomeAccount, 90.0), + credit(taxLiabilityAccount, 10.0)); + }); + } + + // ----------------------------------------------------------------------- + // 11. Accrual accounting: accrual and repayment with tax on fee charge + // ----------------------------------------------------------------------- + + /** + * Periodic-accrual accounting: when a taxed fee charge is accrued the income and liability are recognised + * immediately; at repayment time the receivable is cleared in full with no further tax entries. + * + *
+     * Accrual (charge application):
+     *   DR  Fees Receivable      100.00
+     *   CR  Income from Fees      90.00
+     *   CR  Tax Liability         10.00
+     *
+     * Repayment:
+     *   DR  Fund Source          1100.00
+     *   CR  Loan Portfolio       1000.00
+     *   CR  Fees Receivable       100.00
+     * 
+ * + * The disbursement journal entries are also verified. + */ + @Test + public void testAccrualAccounting_journalEntries_feeWithTaxSplitsIncomeAndLiabilityAtAccrual() { + runAt(LOAN_DATE, () -> { + // Given – a liability account to receive the tax portion + Account taxLiabilityAccount = accountHelper.createLiabilityAccount("taxLiability_accrual"); + + // Given – tax infrastructure linked to the tax liability account + PostTaxesComponentsResponse taxComponent = createTaxComponent(10.0f, taxLiabilityAccount.getAccountID().longValue()); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + + // Given – flat charge of 100 with 10 % tax, due on the loan start date + PostChargesResponse chargeResponse = createFlatLoanCharge(100.0, taxGroup.getResourceId()); + + // Given – a periodic-accrual loan (principal = 1000, no interest) + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = loanProductHelper.createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()) + .getResourceId(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId, BigDecimal.valueOf(1000.0), LOAN_DATE); + + // When – add the taxed charge and run periodic accrual on the same date + loanTransactionHelper.addChargesForLoan(loanId, new PostLoansLoanIdChargesRequest().chargeId(chargeResponse.getResourceId()) + .amount(100.0).dueDate(LOAN_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + + PeriodicAccrualAccountingHelper accrualHelper = new PeriodicAccrualAccountingHelper(requestSpec, responseSpec); + accrualHelper.runPeriodicAccrualAccounting(LOAN_DATE); + + // Make a full repayment (principal 1000 + fee 100 = 1100) + addRepaymentForLoan(loanId, 1100.0, LOAN_DATE); + + // Then – verify ALL journal entries: + // Disbursement: DR Loan Portfolio 1000 / CR Fund Source 1000 + // Accrual: DR Fees Receivable 100 / CR Income from Fees 90 / CR Tax Liability 10 + // Repayment: DR Fund Source 1100 / CR Loan Portfolio 1000 / CR Fees Receivable 100 + verifyJournalEntries(loanId, + // disbursement + debit(loansReceivableAccount, 1000.0), credit(fundSource, 1000.0), + // accrual + debit(feeReceivableAccount, 100.0), credit(feeIncomeAccount, 90.0), credit(taxLiabilityAccount, 10.0), + // repayment + debit(fundSource, 1100.0), credit(loansReceivableAccount, 1000.0), credit(feeReceivableAccount, 100.0)); + }); + } + + // ----------------------------------------------------------------------- + // 9. Full charge list on a loan reflects tax-inflated amounts + // ----------------------------------------------------------------------- + + /** + * GET /loans/{loanId}/charges must return the taxed charge with the original base amount (tax stored separately) + * when listing all charges for the loan. + */ + @Test + public void testLoanChargeList_containsOriginalAmount() { + runAt(LOAN_DATE, () -> { + // Given + PostTaxesComponentsResponse taxComponent = createTaxComponent(10.0f); + PostTaxesGroupResponse taxGroup = createTaxGroup(taxComponent.getResourceId()); + PostChargesResponse chargeResponse = createFlatLoanCharge(100.0, taxGroup.getResourceId()); + + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = loanProductHelper.createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()) + .getResourceId(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, LOAN_DATE, 1000.0); + disburseLoan(loanId, BigDecimal.valueOf(1000.0), LOAN_DATE); + + PostLoansLoanIdChargesResponse addResult = loanTransactionHelper.addChargesForLoan(loanId, new PostLoansLoanIdChargesRequest() + .chargeId(chargeResponse.getResourceId()).amount(100.0).dueDate(DUE_DATE).dateFormat(DATE_FORMAT).locale(LOCALE)); + Long loanChargeId = addResult.getResourceId(); + + // When – retrieve the full charge list + List charges = loanTransactionHelper.getLoanCharges(loanId); + + // Then – the taxed charge must appear in the list with the correct amount + assertNotNull(charges); + GetLoansLoanIdChargesChargeIdResponse found = charges.stream().filter(c -> loanChargeId.equals(c.getId())).findFirst() + .orElse(null); + assertNotNull(found, "The added loan charge must appear in the charge list"); + assertEquals(100.0, found.getAmount(), 0.01, + "Listed charge amount must remain 100; the 10 % tax is stored separately as taxAmount"); + }); + } +}