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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions fineract-client/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
adamsaghy marked this conversation as resolved.
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
Expand Down
1 change: 1 addition & 0 deletions fineract-doc/src/docs/en/chapters/features/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
128 changes: 128 additions & 0 deletions fineract-doc/src/docs/en/chapters/features/taxes-on-loan-charges.adoc
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCharge</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails</class>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCharge</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails</class>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,5 +35,6 @@ public class LoanChargePaidByDTO {
private Long loanChargeId;
private BigDecimal amount;
private Integer installmentNumber;
private List<ChargeTaxDetailDTO> taxDetails = new ArrayList<>();

}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@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;

Expand Down Expand Up @@ -143,6 +146,9 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@OneToMany(mappedBy = "loanCharge", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private Set<LoanChargePaidBy> loanChargePaidBySet = new HashSet<>();

@OneToMany(mappedBy = "loanCharge", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<LoanChargeTaxDetails> taxDetails = new ArrayList<>();

public void markAsFullyPaid() {
this.amountPaid = this.amount;
this.amountOutstanding = BigDecimal.ZERO;
Expand Down Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long> {

@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() {

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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<LoanCharge> charges = loan.getActiveCharges();
Expand Down Expand Up @@ -393,6 +399,7 @@ public Map<String, Object> update(final JsonCommand command, final BigDecimal am
break;
}
loanCharge.setAmountOrPercentage(newValue);
applyTaxIfConfigured(loanCharge);
if (loanCharge.isInstalmentFee()) {
updateInstallmentCharges(loanCharge);
}
Expand Down Expand Up @@ -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<TaxComponent, BigDecimal> 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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading