Skip to content

Commit 92aa4cd

Browse files
FINERACT-1289: Taxes on Loan charges
1 parent ca738eb commit 92aa4cd

31 files changed

Lines changed: 1772 additions & 85 deletions

File tree

fineract-client/build.gradle

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ task buildTypescriptAngularSdk(type: org.openapitools.generator.gradle.plugin.ta
103103
dependsOn(':fineract-provider:resolve')
104104
}
105105

106+
task buildTypescriptFetchSdk(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
107+
generatorName = 'typescript-fetch'
108+
verbose = false
109+
validateSpec = false
110+
skipValidateSpec = true
111+
inputSpec = "file:///$swaggerFile"
112+
outputDir = "$buildDir/generated/typescript-fetch".toString()
113+
configOptions = [
114+
npmName: '@apache/fineract-client-fetch',
115+
npmVersion: '1.12.0-SNAPSHOT',
116+
typescriptThreePlus: 'true',
117+
supportsES6: 'true',
118+
withInterfaces: 'true'
119+
]
120+
dependsOn(':fineract-provider:resolve')
121+
}
122+
106123
task buildAsciidoc(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask){
107124
generatorName = 'asciidoc'
108125
verbose = false

fineract-doc/src/docs/en/chapters/features/index.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ include::delayed-schedule-captures.adoc[leveloffset=+1]
1818
include::loan-origination-details.adoc[leveloffset=+1]
1919
include::working-capital-amortization-schedule.adoc[leveloffset=+1]
2020
include::working-capital-credit-balance-refund.adoc[leveloffset=+1]
21+
include::taxes-on-loan-charges.adoc[leveloffset=+1]
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
= Taxes on Loan Charges
2+
3+
[NOTE]
4+
====
5+
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.
6+
====
7+
8+
== Overview
9+
10+
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.
11+
12+
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.
13+
14+
=== Benefits
15+
16+
* Tax liability GL accounts are credited correctly when a taxed charge is collected.
17+
* Income accounts reflect the net-of-tax charge amount rather than the gross amount.
18+
* Per-component tax breakdowns are persisted, enabling audit trails and reporting.
19+
* Both accrual and cash accounting modes handle tax bifurcation consistently.
20+
21+
== Design
22+
23+
=== Key Components
24+
25+
|===
26+
| Component | Module | Purpose
27+
28+
| `LoanChargeTaxDetails`
29+
| `fineract-loan`
30+
| JPA entity (`m_loan_charge_tax_details`) storing the computed tax amount per `TaxComponent` for a single `LoanCharge`.
31+
32+
| `ChargeTaxApplicationService` / `ChargeTaxApplicationServiceImpl`
33+
| `fineract-tax`
34+
| Computes the per-component tax split from a `TaxGroup`, a base amount, and an effective date.
35+
36+
| `LoanChargeService`
37+
| `fineract-loan`
38+
| Calls `applyTaxIfConfigured()` after every charge amount mutation (set, update, recalculate) to keep tax details in sync.
39+
40+
| `ChargeTaxPaymentDTO`
41+
| `fineract-provider`
42+
| Carries per-component tax payment data (charge ID, credit GL account ID, amount, penalty flag) from the loan transaction to the accounting layer.
43+
44+
| `LoanCommonAccountingHelper`
45+
| `fineract-provider`
46+
| Shared helper that filters tax payments by type (fee/penalty), computes net charge amounts, and creates credit/debit journal entries for tax liability accounts.
47+
48+
| `AccrualBasedAccountingProcessorForLoan`
49+
| `fineract-provider`
50+
| Extended to split fee and penalty journal entries into net income and tax liability entries when tax payments are present.
51+
52+
| `CashBasedAccountingProcessorForLoan`
53+
| `fineract-provider`
54+
| Extended with the same tax bifurcation logic for cash-basis accounting.
55+
|===
56+
57+
=== Data Model
58+
59+
Two schema changes are introduced by migration `1035_add_tax_to_loan_charge.xml`.
60+
61+
[source,sql]
62+
----
63+
-- Column added to m_loan_charge to store the total tax amount for the charge
64+
ALTER TABLE m_loan_charge
65+
ADD COLUMN tax_amount DECIMAL(19,6) NULL;
66+
67+
-- New table storing the per-component tax breakdown for each loan charge
68+
CREATE TABLE m_loan_charge_tax_details (
69+
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
70+
loan_charge_id BIGINT NOT NULL, -- FK → m_loan_charge.id
71+
tax_component_id BIGINT NOT NULL, -- FK → m_tax_component.id
72+
amount DECIMAL(19,6) NOT NULL
73+
);
74+
----
75+
76+
=== Accounting Impact
77+
78+
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.
79+
80+
==== Accrual-Based Accounting — Fee Payment
81+
82+
|===
83+
| Side | Account | Amount
84+
85+
| DR | Fees Receivable | Full fee amount (including tax)
86+
| CR | Income from Fees | Net fee amount (fee minus tax)
87+
| CR | Tax Liability (per component) | Tax amount
88+
|===
89+
90+
==== Accrual-Based Accounting — Fee Reversal
91+
92+
|===
93+
| Side | Account | Amount
94+
95+
| CR | Fees Receivable | Full fee amount (including tax)
96+
| DR | Income from Fees | Net fee amount
97+
| DR | Tax Liability (per component) | Tax amount
98+
|===
99+
100+
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.
101+
102+
If no tax group is configured on the charge, or the computed tax is zero, the existing single-entry behaviour is preserved.
103+
104+
== Configuration
105+
106+
=== Prerequisites
107+
108+
. Create one or more *Tax Components* (Administration → Tax Configuration → Tax Components) with the applicable percentage rate.
109+
. Create a *Tax Group* that references the tax components.
110+
. On the *Charge product*, assign the tax group under the _Tax Group_ field.
111+
. Add the charge to a *Loan Product* that has either *Periodic Accrual* or *Cash-Based* accounting enabled.
112+
113+
=== Validation Rules
114+
115+
* Tax is computed at the time the charge amount is set or updated; a later change to the charge amount triggers recomputation.
116+
* 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.
117+
* 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.
118+
119+
== Usage Example
120+
121+
. Configure a tax component "VAT 16%" and a tax group "Standard VAT".
122+
. Create a flat loan fee of 1,000 and link it to the "Standard VAT" group.
123+
. Add the fee to a loan product with periodic accrual accounting.
124+
. After disbursement, the fee appears with `amount = 1,000` and `tax_amount = 160`.
125+
. When the borrower repays the charge the system posts:
126+
** DR Fees Receivable 1,000
127+
** CR Income from Fees 840
128+
** CR Tax Liability 160

fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory</class>
114114
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCharge</class>
115115
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy</class>
116+
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails</class>
116117
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement</class>
117118
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule</class>
118119
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails</class>

fineract-loan-origination/src/main/resources/jpa/static-weaving/module/fineract-loan-origination/persistence.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory</class>
104104
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCharge</class>
105105
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy</class>
106+
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails</class>
106107
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement</class>
107108
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule</class>
108109
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails</class>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.loanaccount.data;
20+
21+
import java.math.BigDecimal;
22+
import lombok.AllArgsConstructor;
23+
import lombok.Data;
24+
import lombok.NoArgsConstructor;
25+
26+
/**
27+
* Carries the pro-rated tax amount for a single TaxComponent when a LoanCharge is (partially) paid. Used to propagate
28+
* tax details from the domain layer to the accounting bridge.
29+
*/
30+
@Data
31+
@NoArgsConstructor
32+
@AllArgsConstructor
33+
public class ChargeTaxDetailDTO {
34+
35+
/** GL account to credit (tax liability account from TaxComponent.creditAccount). */
36+
private Long creditAccountId;
37+
38+
/** Pro-rated tax amount for this component in this payment. */
39+
private BigDecimal amount;
40+
}

fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanChargePaidByDTO.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
package org.apache.fineract.portfolio.loanaccount.data;
2020

2121
import java.math.BigDecimal;
22+
import java.util.ArrayList;
23+
import java.util.List;
2224
import lombok.AllArgsConstructor;
2325
import lombok.Data;
2426
import lombok.NoArgsConstructor;
@@ -33,5 +35,6 @@ public class LoanChargePaidByDTO {
3335
private Long loanChargeId;
3436
private BigDecimal amount;
3537
private Integer installmentNumber;
38+
private List<ChargeTaxDetailDTO> taxDetails = new ArrayList<>();
3639

3740
}

fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
108108
@Column(name = "amount_outstanding_derived", scale = 6, precision = 19, nullable = false)
109109
private BigDecimal amountOutstanding;
110110

111+
@Column(name = "tax_amount", scale = 6, precision = 19)
112+
private BigDecimal taxAmount = BigDecimal.ZERO;
113+
111114
@Column(name = "is_penalty", nullable = false)
112115
private boolean penaltyCharge = false;
113116

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

149+
@OneToMany(mappedBy = "loanCharge", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
150+
private List<LoanChargeTaxDetails> taxDetails = new ArrayList<>();
151+
146152
public void markAsFullyPaid() {
147153
this.amountPaid = this.amount;
148154
this.amountOutstanding = BigDecimal.ZERO;
@@ -412,6 +418,10 @@ public Money getAmountWrittenOff(final MonetaryCurrency currency) {
412418
return Money.of(currency, this.amountWrittenOff);
413419
}
414420

421+
public Money getTaxAmount(final MonetaryCurrency currency) {
422+
return Money.of(currency, getTaxAmount());
423+
}
424+
415425
/**
416426
* @param incrementBy
417427
*
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.loanaccount.domain;
20+
21+
import jakarta.persistence.Column;
22+
import jakarta.persistence.Entity;
23+
import jakarta.persistence.JoinColumn;
24+
import jakarta.persistence.ManyToOne;
25+
import jakarta.persistence.Table;
26+
import java.math.BigDecimal;
27+
import lombok.AllArgsConstructor;
28+
import lombok.Getter;
29+
import lombok.Setter;
30+
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
31+
import org.apache.fineract.portfolio.tax.domain.TaxComponent;
32+
33+
@AllArgsConstructor
34+
@Setter
35+
@Getter
36+
@Entity
37+
@Table(name = "m_loan_charge_tax_details")
38+
public class LoanChargeTaxDetails extends AbstractPersistableCustom<Long> {
39+
40+
@ManyToOne
41+
@JoinColumn(name = "loan_charge_id", nullable = false)
42+
private LoanCharge loanCharge;
43+
44+
@ManyToOne
45+
@JoinColumn(name = "tax_component_id", nullable = false)
46+
private TaxComponent taxComponent;
47+
48+
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
49+
private BigDecimal amount;
50+
51+
public LoanChargeTaxDetails() {
52+
53+
}
54+
}

fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeService.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
4444
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
4545
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy;
46+
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargeTaxDetails;
4647
import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails;
4748
import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent;
4849
import org.apache.fineract.portfolio.loanaccount.domain.LoanInstallmentCharge;
@@ -56,6 +57,10 @@
5657
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
5758
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
5859
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
60+
import org.apache.fineract.portfolio.tax.domain.TaxComponent;
61+
import org.apache.fineract.portfolio.tax.domain.TaxGroup;
62+
import org.apache.fineract.portfolio.tax.service.ChargeTaxApplicationService;
63+
import org.apache.fineract.portfolio.tax.service.TaxUtils;
5964

6065
@RequiredArgsConstructor
6166
public class LoanChargeService {
@@ -65,6 +70,7 @@ public class LoanChargeService {
6570
private final LoanLifecycleStateMachine loanLifecycleStateMachine;
6671
private final LoanBalanceService loanBalanceService;
6772
private final LoanScheduleGeneratorService loanScheduleGeneratorService;
73+
private final ChargeTaxApplicationService chargeTaxApplicationService;
6874

6975
public void recalculateAllCharges(final Loan loan) {
7076
Set<LoanCharge> charges = loan.getActiveCharges();
@@ -393,6 +399,7 @@ public Map<String, Object> update(final JsonCommand command, final BigDecimal am
393399
break;
394400
}
395401
loanCharge.setAmountOrPercentage(newValue);
402+
applyTaxIfConfigured(loanCharge);
396403
if (loanCharge.isInstalmentFee()) {
397404
updateInstallmentCharges(loanCharge);
398405
}
@@ -445,11 +452,30 @@ public void populateDerivedFields(final LoanCharge loanCharge, final BigDecimal
445452
break;
446453
}
447454
loanCharge.setAmountOrPercentage(chargeAmount);
455+
applyTaxIfConfigured(loanCharge);
448456
if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) {
449457
updateInstallmentCharges(loanCharge);
450458
}
451459
}
452460

461+
private void applyTaxIfConfigured(final LoanCharge loanCharge) {
462+
TaxGroup taxGroup = loanCharge.getCharge().getTaxGroup();
463+
if (taxGroup == null || loanCharge.getAmount() == null) {
464+
return;
465+
}
466+
LocalDate effectiveDate = loanCharge.getSubmittedOnDate() != null ? loanCharge.getSubmittedOnDate()
467+
: DateUtils.getBusinessLocalDate();
468+
Map<TaxComponent, BigDecimal> taxSplit = chargeTaxApplicationService.computeTax(taxGroup, loanCharge.getAmount(), effectiveDate, 6);
469+
BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit);
470+
if (totalTax.compareTo(BigDecimal.ZERO) == 0) {
471+
return;
472+
}
473+
loanCharge.setTaxAmount(totalTax);
474+
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
475+
loanCharge.getTaxDetails().clear();
476+
taxSplit.forEach((component, taxAmt) -> loanCharge.getTaxDetails().add(new LoanChargeTaxDetails(loanCharge, component, taxAmt)));
477+
}
478+
453479
public void update(final LoanCharge loanCharge, final BigDecimal amount, final LocalDate dueDate, final Integer numberOfRepayments) {
454480
BigDecimal amountPercentageAppliedTo = BigDecimal.ZERO;
455481
if (loanCharge.getLoan() != null) {
@@ -814,6 +840,7 @@ private void update(final LoanCharge loanCharge, final BigDecimal amount, final
814840
break;
815841
}
816842
loanCharge.setAmountOrPercentage(amount);
843+
applyTaxIfConfigured(loanCharge);
817844
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
818845
if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) {
819846
updateInstallmentCharges(loanCharge);
@@ -854,6 +881,7 @@ private void update(final LoanCharge loanCharge, final BigDecimal amount, final
854881
break;
855882
}
856883
loanCharge.setAmountOrPercentage(amount);
884+
applyTaxIfConfigured(loanCharge);
857885
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
858886
if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) {
859887
updateInstallmentCharges(loanCharge, transactionDate);

0 commit comments

Comments
 (0)