This is an automated email from the ASF dual-hosted git repository.

adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git

commit 115063b89e00a942faa07819748ffb1fd95e4bff
Author: Attila Budai <[email protected]>
AuthorDate: Wed Feb 11 08:14:02 2026 +0100

    FINERACT-2466: fix trial balance report
    
    Signed-off-by: Attila Budai <[email protected]>
---
 .../db/changelog/tenant/changelog-tenant.xml       |   1 +
 ...summary_fix_external_owners_and_aggregation.xml | 242 ++++++++++++
 .../client/feign/helpers/FeignAccountHelper.java   |   6 +
 .../client/feign/helpers/FeignSchedulerHelper.java |  74 ++++
 .../tests/FeignTrialBalanceSummaryReportTest.java  | 427 +++++++++++++++++++++
 5 files changed, 750 insertions(+)

diff --git 
a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml 
b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
index 55f8380e1e..297426a0ac 100644
--- 
a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
+++ 
b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
@@ -229,4 +229,5 @@
     <include 
file="parts/0208_trial_balance_summary_with_asset_owner_journal_entry_aggregation_fix.xml"
 relativeToChangelogFile="true" />
     <include 
file="parts/0209_transaction_summary_with_asset_owner_and_from_asset_owner_id_for_asset_sales.xml"
 relativeToChangelogFile="true" />
     <include 
file="parts/0210_add_configuration_password_reuse_check_history_count.xml" 
relativeToChangelogFile="true" />
+    <include 
file="parts/0211_trial_balance_summary_fix_external_owners_and_aggregation.xml" 
relativeToChangelogFile="true" />
 </databaseChangeLog>
diff --git 
a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0211_trial_balance_summary_fix_external_owners_and_aggregation.xml
 
b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0211_trial_balance_summary_fix_external_owners_and_aggregation.xml
new file mode 100644
index 0000000000..94bca487eb
--- /dev/null
+++ 
b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0211_trial_balance_summary_fix_external_owners_and_aggregation.xml
@@ -0,0 +1,242 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    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.
+
+-->
+<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog";
+                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+                   
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog 
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd";>
+
+    <changeSet author="fineract" 
id="trial-balance-summary-with-asset-owner-update-5">
+        <update tableName="stretchy_report">
+            <column name="report_sql">
+                <![CDATA[
+WITH retained_earning AS (
+    SELECT DISTINCT
+        '${endDate}' AS postingdate,
+        lp.name AS product,
+        gl_code AS glacct,
+        COALESCE((SELECT name FROM acc_gl_account WHERE gl_code = e.gl_code), 
'') AS description,
+        COALESCE(e.owner_external_id, 'self') AS assetowner,
+        SUM(opening_balance_amount) AS beginningbalance,
+        0 AS debitmovement,
+        0 AS creditmovement,
+        SUM(opening_balance_amount) AS endingbalance
+    FROM acc_gl_journal_entry_annual_summary e, m_product_loan lp
+    WHERE e.office_id = ${officeId}
+      AND lp.id = product_id
+      AND EXTRACT(YEAR FROM e.year_end_date) < EXTRACT(YEAR FROM 
CAST('${endDate}' AS DATE))
+    GROUP BY gl_code, lp.name, office_id, owner_external_id
+),
+aggregated_date AS (
+    SELECT MAX(aggregated_on_date_to) AS latest
+    FROM m_journal_entry_aggregation_tracking
+    WHERE aggregated_on_date_to < '${endDate}'
+),
+summary_snapshot_baseline_data AS (
+    SELECT
+        lp.NAME AS productname,
+        acc_gl_account.gl_code AS glcode,
+        acc_gl_account.NAME AS glname,
+        CASE WHEN ags.external_owner_id IS NULL THEN 0 ELSE 
ags.external_owner_id END AS assetowner,
+        SUM(ags.debit_amount) AS debitamount,
+        SUM(ags.credit_amount) AS creditamount
+    FROM acc_gl_account
+    JOIN m_journal_entry_aggregation_summary ags ON acc_gl_account.id = 
ags.gl_account_id
+    JOIN m_product_loan lp ON lp.id = ags.product_id
+    WHERE ags.entity_type_enum = 1
+      AND ags.manual_entry = FALSE
+      AND ags.aggregated_on_date <= (SELECT latest FROM aggregated_date)
+      AND (ags.office_id = ${officeId})
+    GROUP BY productname, glcode, glname, assetowner
+),
+post_snapshot_delta_data AS (
+    SELECT
+        lp.NAME AS productname,
+        acc_gl_account.gl_code AS glcode,
+        acc_gl_account.NAME AS glname,
+        CASE WHEN aw.owner_id IS NULL THEN 0 ELSE aw.owner_id END AS 
assetowner,
+        SUM(CASE WHEN acc_gl_journal_entry.type_enum = 2 THEN amount ELSE 0 
END) AS debitamount,
+        SUM(CASE WHEN acc_gl_journal_entry.type_enum = 1 THEN amount ELSE 0 
END) AS creditamount
+    FROM acc_gl_account
+    JOIN acc_gl_journal_entry ON acc_gl_account.id = 
acc_gl_journal_entry.account_id
+    JOIN m_loan m ON m.id = acc_gl_journal_entry.entity_id
+    JOIN m_product_loan lp ON lp.id = m.product_id
+    LEFT JOIN m_external_asset_owner_journal_entry_mapping aw ON 
aw.journal_entry_id = acc_gl_journal_entry.id
+    WHERE acc_gl_journal_entry.entity_type_enum = 1
+      AND acc_gl_journal_entry.manual_entry = FALSE
+      AND (
+            (SELECT latest FROM aggregated_date) IS NULL
+            OR acc_gl_journal_entry.submitted_on_date > (SELECT latest FROM 
aggregated_date)
+          )
+      AND acc_gl_journal_entry.submitted_on_date < '${endDate}'
+      AND (acc_gl_journal_entry.office_id = ${officeId})
+    GROUP BY productname, glcode, glname, assetowner
+),
+merged_historical_data AS (
+    SELECT
+        COALESCE(s.productname, p.productname) AS productname,
+        COALESCE(s.glcode, p.glcode)          AS glcode,
+        COALESCE(s.glname, p.glname)          AS glname,
+        COALESCE(s.assetowner, p.assetowner, 0) AS assetowner,
+        COALESCE(s.debitamount, 0)  + COALESCE(p.debitamount, 0)  AS 
debitamount,
+        COALESCE(s.creditamount, 0) + COALESCE(p.creditamount, 0) AS 
creditamount
+    FROM summary_snapshot_baseline_data s
+    LEFT JOIN post_snapshot_delta_data p
+        ON s.glcode = p.glcode
+       AND s.productname = p.productname
+       AND s.assetowner = p.assetowner
+
+    UNION ALL
+
+    SELECT
+        p.productname AS productname,
+        p.glcode      AS glcode,
+        p.glname      AS glname,
+        COALESCE(p.assetowner, 0) AS assetowner,
+        COALESCE(p.debitamount, 0)  AS debitamount,
+        COALESCE(p.creditamount, 0) AS creditamount
+    FROM post_snapshot_delta_data p
+    LEFT JOIN summary_snapshot_baseline_data s
+        ON s.glcode = p.glcode
+       AND s.productname = p.productname
+       AND s.assetowner = p.assetowner
+    WHERE s.glcode IS NULL
+),
+current_cob_data AS (
+    SELECT
+        lp.name AS productname,
+        account_id,
+        acc_gl_account.gl_code AS glcode,
+        acc_gl_account.name AS glname,
+        CASE WHEN aw.owner_id IS NULL THEN 0 ELSE aw.owner_id END AS 
assetowner,
+        SUM(CASE WHEN acc_gl_journal_entry.type_enum = 2 THEN amount ELSE 0 
END) AS debitamount,
+        SUM(CASE WHEN acc_gl_journal_entry.type_enum = 1 THEN amount ELSE 0 
END) AS creditamount
+    FROM acc_gl_journal_entry
+    JOIN acc_gl_account ON acc_gl_account.id = acc_gl_journal_entry.account_id
+    JOIN m_loan m ON m.id = acc_gl_journal_entry.entity_id
+    JOIN m_product_loan lp ON lp.id = m.product_id
+    LEFT JOIN m_external_asset_owner_journal_entry_mapping aw ON 
aw.journal_entry_id = acc_gl_journal_entry.id
+    WHERE acc_gl_journal_entry.entity_type_enum = 1
+      AND acc_gl_journal_entry.manual_entry = FALSE
+      AND acc_gl_journal_entry.submitted_on_date = '${endDate}'
+      AND (acc_gl_journal_entry.office_id = ${officeId})
+    GROUP BY productname, account_id, glcode, glname, assetowner
+)
+
+SELECT *
+FROM (
+    SELECT *
+    FROM retained_earning
+    WHERE glacct = (SELECT gl_code FROM acc_gl_account WHERE name = 'Retained 
Earnings Prior Year')
+
+    UNION
+
+    SELECT
+        txnreport.postingdate,
+        txnreport.product,
+        txnreport.glacct,
+        txnreport.description,
+        txnreport.assetowner,
+        (COALESCE(txnreport.beginningbalance, 0) + 
COALESCE(summary.beginningbalance, 0)) AS beginningbalance,
+        txnreport.debitmovement AS debitmovement,
+        txnreport.creditmovement AS creditmovement,
+        (COALESCE(txnreport.endingbalance, 0) + 
COALESCE(summary.beginningbalance, 0)) AS endingbalance
+    FROM (
+        SELECT *
+        FROM (
+            SELECT DISTINCT
+                '${endDate}' AS postingdate,
+                loan.pname AS product,
+                loan.gl_code AS glacct,
+                loan.glname AS description,
+                COALESCE((SELECT external_id FROM m_external_asset_owner WHERE 
id = loan.assetowner), 'self') AS assetowner,
+                loan.openingbalance AS beginningbalance,
+                (loan.debitamount * 1) AS debitmovement,
+                (loan.creditamount * -1) AS creditmovement,
+                (loan.openingbalance + loan.debitamount - loan.creditamount) 
AS endingbalance
+            FROM (
+                SELECT DISTINCT
+                    g.pname AS pname,
+                    g.gl_code AS gl_code,
+                    g.glname AS glname,
+                    COALESCE(mh.assetowner, c.assetowner, 0) AS assetowner,
+                    COALESCE(mh.debitamount, 0) - COALESCE(mh.creditamount, 0) 
AS openingbalance,
+                    COALESCE(c.debitamount, 0) AS debitamount,
+                    COALESCE(c.creditamount, 0) AS creditamount
+                FROM (
+                    SELECT DISTINCT ag.gl_code, ag.id, pl.NAME AS pname, 
ag.NAME AS glname
+                    FROM acc_gl_account ag
+                    JOIN acc_product_mapping am ON am.gl_account_id = ag.id 
AND am.product_type = 1
+                    JOIN m_product_loan pl ON pl.id = am.product_id
+                ) g
+                LEFT JOIN merged_historical_data mh
+                    ON g.gl_code = mh.glcode
+                   AND mh.productname = g.pname
+                LEFT JOIN current_cob_data c
+                    ON g.gl_code = c.glcode
+                   AND c.productname = g.pname
+                   AND mh.assetowner = c.assetowner
+
+                UNION ALL
+
+                SELECT DISTINCT
+                    c.productname AS pname,
+                    c.glcode      AS gl_code,
+                    c.glname      AS glname,
+                    COALESCE(c.assetowner, 0) AS assetowner,
+                    0 AS openingbalance,
+                    COALESCE(c.debitamount, 0) AS debitamount,
+                    COALESCE(c.creditamount, 0) AS creditamount
+                FROM current_cob_data c
+                LEFT JOIN (
+                    SELECT g3.gl_code, g3.pname, mh.assetowner
+                    FROM (
+                        SELECT DISTINCT ag.gl_code, pl.NAME AS pname
+                        FROM acc_gl_account ag
+                        JOIN acc_product_mapping am ON am.gl_account_id = 
ag.id AND am.product_type = 1
+                        JOIN m_product_loan pl ON pl.id = am.product_id
+                    ) g3
+                    LEFT JOIN merged_historical_data mh
+                        ON g3.gl_code = mh.glcode
+                       AND mh.productname = g3.pname
+                ) matched
+                    ON matched.gl_code = c.glcode
+                   AND matched.pname = c.productname
+                   AND matched.assetowner = c.assetowner
+                WHERE matched.gl_code IS NULL
+            ) loan
+        ) a
+    ) AS txnreport
+    LEFT JOIN retained_earning summary
+        ON txnreport.glacct = summary.glacct
+       AND txnreport.assetowner = summary.assetowner
+       AND summary.product = txnreport.product
+) report
+WHERE report.endingbalance != 0
+   OR report.debitmovement != 0
+   OR report.creditmovement != 0
+ORDER BY glacct
+
+                ]]>
+            </column>
+            <where>report_name='Trial Balance Summary Report with Asset 
Owner'</where>
+        </update>
+    </changeSet>
+</databaseChangeLog>
diff --git 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java
 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java
index 4dc91104c1..163e968e40 100644
--- 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java
+++ 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java
@@ -71,6 +71,12 @@ public class FeignAccountHelper {
         return new Account(account.getId().intValue(), getAccountType(type));
     }
 
+    public String getGlCode(Account account) {
+        GetGLAccountsResponse response = ok(
+                () -> 
fineractClient.generalLedgerAccount().retreiveAccount(account.getAccountID().longValue(),
 Collections.emptyMap()));
+        return response.getGlCode();
+    }
+
     private Integer getAccountTypeId(String type) {
         return switch (type) {
             case "ASSET" -> 1;
diff --git 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSchedulerHelper.java
 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSchedulerHelper.java
new file mode 100644
index 0000000000..0528639f7b
--- /dev/null
+++ 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSchedulerHelper.java
@@ -0,0 +1,74 @@
+/**
+ * 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.client.feign.helpers;
+
+import static org.apache.fineract.client.feign.util.FeignCalls.ok;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.feign.util.FeignCalls;
+import org.apache.fineract.client.models.ExecuteJobRequest;
+import org.apache.fineract.client.models.GetJobsResponse;
+import org.apache.fineract.client.models.JobDetailHistoryData;
+import org.awaitility.Awaitility;
+
+public class FeignSchedulerHelper {
+
+    private final FineractFeignClient fineractClient;
+
+    public FeignSchedulerHelper(FineractFeignClient fineractClient) {
+        this.fineractClient = fineractClient;
+    }
+
+    public void stopScheduler() {
+        FeignCalls.executeVoid(() -> 
fineractClient.scheduler().changeSchedulerStatus("stop"));
+    }
+
+    public void startScheduler() {
+        FeignCalls.executeVoid(() -> 
fineractClient.scheduler().changeSchedulerStatus("start"));
+    }
+
+    public void executeAndAwaitJob(String jobDisplayName) {
+        stopScheduler();
+
+        List<GetJobsResponse> allJobs = ok(() -> 
fineractClient.schedulerJob().retrieveAll8());
+        GetJobsResponse targetJob = allJobs.stream().filter(j -> 
jobDisplayName.equals(j.getDisplayName())).findFirst()
+                .orElseThrow(() -> new RuntimeException("Job not found: " + 
jobDisplayName));
+
+        Instant beforeExecuteTime = 
Instant.now().truncatedTo(ChronoUnit.SECONDS);
+        FeignCalls.executeVoid(() -> 
fineractClient.schedulerJob().executeJob(targetJob.getJobId(), "executeJob", 
new ExecuteJobRequest()));
+
+        
Awaitility.await().atMost(Duration.ofMinutes(2)).pollInterval(Duration.ofSeconds(1)).pollDelay(Duration.ofSeconds(1)).until(()
 -> {
+            GetJobsResponse job = ok(() -> 
fineractClient.schedulerJob().retrieveOne5(targetJob.getJobId()));
+            JobDetailHistoryData history = job.getLastRunHistory();
+            if (history == null || history.getJobRunStartTime() == null) {
+                return false;
+            }
+            OffsetDateTime startTime = history.getJobRunStartTime();
+            if (startTime.toInstant().isBefore(beforeExecuteTime)) {
+                return false;
+            }
+            return history.getJobRunEndTime() != null && 
!history.getJobRunEndTime().toInstant().isBefore(startTime.toInstant());
+        });
+    }
+}
diff --git 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignTrialBalanceSummaryReportTest.java
 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignTrialBalanceSummaryReportTest.java
new file mode 100644
index 0000000000..a33dfcd0b9
--- /dev/null
+++ 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignTrialBalanceSummaryReportTest.java
@@ -0,0 +1,427 @@
+/**
+ * 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.client.feign.tests;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.client.models.BusinessStep;
+import org.apache.fineract.client.models.BusinessStepRequest;
+import org.apache.fineract.client.models.ChargeRequest;
+import org.apache.fineract.client.models.ExternalAssetOwnerRequest;
+import org.apache.fineract.client.models.GetFinancialActivityAccountsResponse;
+import org.apache.fineract.client.models.PostClientsRequest;
+import org.apache.fineract.client.models.PostFinancialActivityAccountsRequest;
+import org.apache.fineract.client.models.PostInitiateTransferResponse;
+import org.apache.fineract.client.models.PostLoanProductsRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdChargesRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
+import org.apache.fineract.client.models.PostLoansRequest;
+import org.apache.fineract.client.models.ResultsetColumnHeaderData;
+import org.apache.fineract.client.models.ResultsetRowData;
+import org.apache.fineract.client.models.RunReportsResponse;
+import org.apache.fineract.integrationtests.client.FeignIntegrationTest;
+import 
org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper;
+import 
org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper;
+import 
org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper;
+import 
org.apache.fineract.integrationtests.client.feign.helpers.FeignGlobalConfigurationHelper;
+import 
org.apache.fineract.integrationtests.client.feign.helpers.FeignLoanHelper;
+import 
org.apache.fineract.integrationtests.client.feign.helpers.FeignSchedulerHelper;
+import 
org.apache.fineract.integrationtests.client.feign.helpers.FeignTransactionHelper;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData;
+import org.apache.fineract.integrationtests.common.Utils;
+import org.apache.fineract.integrationtests.common.accounting.Account;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+@Slf4j
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class FeignTrialBalanceSummaryReportTest extends FeignIntegrationTest {
+
+    private static final String REPORT_NAME = "Trial Balance Summary Report 
with Asset Owner";
+    private static final BigDecimal LOAN_PRINCIPAL = 
BigDecimal.valueOf(15000.0);
+
+    private FeignAccountHelper accountHelper;
+    private FeignGlobalConfigurationHelper globalConfigHelper;
+    private FeignBusinessDateHelper businessDateHelper;
+    private FeignLoanHelper loanHelper;
+    private FeignTransactionHelper transactionHelper;
+    private FeignClientHelper clientHelper;
+    private FeignSchedulerHelper schedulerHelper;
+
+    private Account assetAccount;
+    private Account feePenaltyAccount;
+    private Account transferAccount;
+    private Account expenseAccount;
+    private Account incomeAccount;
+    private Account overpaymentAccount;
+    private String todaysDate;
+    private List<BusinessStep> originalBusinessSteps;
+    private List<GetFinancialActivityAccountsResponse> 
originalFinancialMappings;
+
+    @BeforeAll
+    public void setup() {
+        accountHelper = new FeignAccountHelper(fineractClient());
+        globalConfigHelper = new 
FeignGlobalConfigurationHelper(fineractClient());
+        businessDateHelper = new FeignBusinessDateHelper(fineractClient());
+        loanHelper = new FeignLoanHelper(fineractClient());
+        transactionHelper = new FeignTransactionHelper(fineractClient());
+        clientHelper = new FeignClientHelper(fineractClient());
+        schedulerHelper = new FeignSchedulerHelper(fineractClient());
+
+        todaysDate = Utils.getLocalDateOfTenant().toString();
+
+        originalBusinessSteps = ok(
+                () -> 
fineractClient().businessStepConfiguration().retrieveAllConfiguredBusinessStep("LOAN_CLOSE_OF_BUSINESS"))
+                .getBusinessSteps();
+        originalFinancialMappings = ok(() -> 
fineractClient().mappingFinancialActivitiesToAccounts().retrieveAll());
+
+        configureBusinessSteps();
+
+        assetAccount = accountHelper.createAssetAccount("TrialBal");
+        feePenaltyAccount = accountHelper.createAssetAccount("TrialBalFP");
+        transferAccount = accountHelper.createAssetAccount("TrialBalTR");
+        expenseAccount = accountHelper.createExpenseAccount("TrialBalEXP");
+        incomeAccount = accountHelper.createIncomeAccount("TrialBalINC");
+        overpaymentAccount = 
accountHelper.createLiabilityAccount("TrialBalOP");
+
+        setupFinancialActivityMapping();
+    }
+
+    @AfterAll
+    public void tearDown() {
+        executeVoid(() -> 
fineractClient().businessStepConfiguration().updateJobBusinessStepConfig("LOAN_CLOSE_OF_BUSINESS",
+                new 
BusinessStepRequest().businessSteps(originalBusinessSteps)));
+        List<GetFinancialActivityAccountsResponse> currentMappings = ok(
+                () -> 
fineractClient().mappingFinancialActivitiesToAccounts().retrieveAll());
+        for (GetFinancialActivityAccountsResponse mapping : currentMappings) {
+            executeVoid(() -> 
fineractClient().mappingFinancialActivitiesToAccounts().deleteGLAccount(mapping.getId()));
+        }
+        for (GetFinancialActivityAccountsResponse mapping : 
originalFinancialMappings) {
+            ok(() -> fineractClient().mappingFinancialActivitiesToAccounts()
+                    .createGLAccount(new PostFinancialActivityAccountsRequest()
+                            
.financialActivityId(Long.valueOf(mapping.getFinancialActivityData().getId()))
+                            .glAccountId(mapping.getGlAccountData().getId())));
+        }
+        schedulerHelper.startScheduler();
+    }
+
+    @Test
+    @Order(1)
+    public void testReportReturnsExpectedColumns() {
+        RunReportsResponse response = runReport("2020-01-01");
+
+        assertNotNull(response);
+        assertNotNull(response.getColumnHeaders());
+        assertFalse(response.getColumnHeaders().isEmpty());
+
+        List<String> expectedColumns = List.of("postingdate", "product", 
"glacct", "description", "assetowner", "beginningbalance",
+                "debitmovement", "creditmovement", "endingbalance");
+        List<String> actualColumns = 
response.getColumnHeaders().stream().map(ResultsetColumnHeaderData::getColumnName).toList();
+        Assertions.assertEquals(expectedColumns, actualColumns);
+    }
+
+    @Test
+    @Order(2)
+    public void testExternalAssetOwnerEntriesAppearInReport() {
+        runWithBusinessDate("2020-03-02", () -> {
+            Long clientId = createClient("01 March 2020");
+            Long loanId = createAndDisburseLoan(clientId, "01 March 2020", "02 
March 2020", null);
+
+            String ownerExternalId = UUID.randomUUID().toString();
+            PostInitiateTransferResponse saleResponse = ok(() -> 
fineractClient().externalAssetOwners().transferRequestWithLoanId(loanId,
+                    new 
ExternalAssetOwnerRequest().settlementDate("2020-03-02").dateFormat("yyyy-MM-dd").locale(LoanTestData.LOCALE)
+                            
.transferExternalId(UUID.randomUUID().toString()).ownerExternalId(ownerExternalId).purchasePriceRatio("1.0"),
+                    "sale"));
+            assertNotNull(saleResponse);
+
+            advanceBusinessDateAndRunCob("2020-03-03");
+
+            RunReportsResponse report = runReport("2020-03-02");
+            assertNotNull(report.getData());
+
+            int assetOwnerColIdx = findColumnIndex(report, "assetowner");
+            boolean hasExternalOwnerEntry = report.getData().stream()
+                    .anyMatch(row -> 
ownerExternalId.equals(String.valueOf(row.getRow().get(assetOwnerColIdx))));
+
+            assertTrue(hasExternalOwnerEntry,
+                    "Report must contain entries for external asset owner '" + 
ownerExternalId + "'. Actual rows: " + report.getData());
+        });
+    }
+
+    @Test
+    @Order(3)
+    public void testBalanceFormulaIsConsistent() {
+        runWithBusinessDate("2020-06-01", () -> {
+            Long clientId = createClient("01 June 2020");
+            Long chargeId = createFlatFeeCharge(500.0);
+            Long loanId = createAndDisburseLoan(clientId, "01 June 2020", "01 
June 2020", chargeId);
+
+            String productName = 
loanHelper.getLoanDetails(loanId).getLoanProductName();
+
+            transactionHelper.addRepayment(loanId, new 
PostLoansLoanIdTransactionsRequest().transactionDate("01 June 2020")
+                    
.transactionAmount(1500.0).locale(LoanTestData.LOCALE).dateFormat(LoanTestData.DATETIME_PATTERN));
+
+            RunReportsResponse report = runReport("2020-06-01");
+            assertNotNull(report.getData());
+
+            int productIdx = findColumnIndex(report, "product");
+            int assetOwnerIdx = findColumnIndex(report, "assetowner");
+            int beginBalIdx = findColumnIndex(report, "beginningbalance");
+            int debitIdx = findColumnIndex(report, "debitmovement");
+            int creditIdx = findColumnIndex(report, "creditmovement");
+            int endingBalIdx = findColumnIndex(report, "endingbalance");
+            int glAcctIdx = findColumnIndex(report, "glacct");
+
+            List<ResultsetRowData> productRows = report.getData().stream()
+                    .filter(row -> 
productName.equals(String.valueOf(row.getRow().get(productIdx)))).toList();
+
+            assertFalse(productRows.isEmpty(), "Report must contain entries 
for product '" + productName + "'.");
+
+            boolean allSelfOwned = productRows.stream().allMatch(row -> 
"self".equals(String.valueOf(row.getRow().get(assetOwnerIdx))));
+            assertTrue(allSelfOwned, "All entries should be 'self' when no 
external asset owner transfer exists.");
+
+            assertTrue(productRows.size() >= 2, "Report must contain at least 
2 GL account rows for a product with charges.");
+
+            String assetGlCode = accountHelper.getGlCode(assetAccount);
+            String feeGlCode = accountHelper.getGlCode(feePenaltyAccount);
+            boolean hasAssetRow = productRows.stream().anyMatch(row -> 
assetGlCode.equals(String.valueOf(row.getRow().get(glAcctIdx))));
+            boolean hasFeeRow = productRows.stream().anyMatch(row -> 
feeGlCode.equals(String.valueOf(row.getRow().get(glAcctIdx))));
+            assertTrue(hasAssetRow, "Report must contain asset account row.");
+            assertTrue(hasFeeRow, "Report must contain fee/penalty receivable 
row.");
+
+            for (ResultsetRowData row : productRows) {
+                BigDecimal beginBal = 
parseBigDecimal(row.getRow().get(beginBalIdx));
+                BigDecimal debit = parseBigDecimal(row.getRow().get(debitIdx));
+                BigDecimal credit = 
parseBigDecimal(row.getRow().get(creditIdx));
+                BigDecimal endBal = 
parseBigDecimal(row.getRow().get(endingBalIdx));
+
+                Assertions.assertEquals(0, BigDecimal.ZERO.compareTo(beginBal),
+                        "Beginning balance should be 0 when no prior year data 
exists. Row: " + row.getRow());
+
+                BigDecimal expectedEndBal = beginBal.add(debit).add(credit);
+                Assertions.assertEquals(0, expectedEndBal.compareTo(endBal),
+                        "Ending balance must equal beginning + debit + credit. 
Expected: " + expectedEndBal + ", Actual: " + endBal);
+            }
+        });
+    }
+
+    @Test
+    @Order(4)
+    public void testNoRetainedEarningsWithoutAnnualSummary() {
+        RunReportsResponse report = runReport("2020-06-01");
+        assertNotNull(report);
+        assertNotNull(report.getData());
+
+        int glAcctIdx = findColumnIndex(report, "glacct");
+        boolean hasRetainedEarningsRow = report.getData().stream()
+                .anyMatch(row -> 
"320000".equals(String.valueOf(row.getRow().get(glAcctIdx))));
+        assertFalse(hasRetainedEarningsRow, "Report should not contain 
Retained Earnings rows when no annual summary data exists.");
+    }
+
+    private void configureBusinessSteps() {
+        List<String> stepNames = List.of("APPLY_CHARGE_TO_OVERDUE_LOANS", 
"LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE",
+                "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", 
"ADD_PERIODIC_ACCRUAL_ENTRIES",
+                "EXTERNAL_ASSET_OWNER_TRANSFER");
+        List<BusinessStep> steps = IntStream.range(0, stepNames.size())
+                .mapToObj(i -> new 
BusinessStep().stepName(stepNames.get(i)).order((long) (i + 
1))).collect(Collectors.toList());
+        executeVoid(() -> 
fineractClient().businessStepConfiguration().updateJobBusinessStepConfig("LOAN_CLOSE_OF_BUSINESS",
+                new BusinessStepRequest().businessSteps(steps)));
+    }
+
+    private void setupFinancialActivityMapping() {
+        List<GetFinancialActivityAccountsResponse> mappings = ok(
+                () -> 
fineractClient().mappingFinancialActivitiesToAccounts().retrieveAll());
+        for (GetFinancialActivityAccountsResponse mapping : mappings) {
+            executeVoid(() -> 
fineractClient().mappingFinancialActivitiesToAccounts().deleteGLAccount(mapping.getId()));
+        }
+        ok(() -> 
fineractClient().mappingFinancialActivitiesToAccounts().createGLAccount(
+                new 
PostFinancialActivityAccountsRequest().financialActivityId(100L).glAccountId((long)
 transferAccount.getAccountID())));
+    }
+
+    private void runWithBusinessDate(String date, Runnable action) {
+        try {
+            
globalConfigHelper.updateConfigurationByName("enable-business-date", true);
+            
globalConfigHelper.updateConfigurationByName("enable-auto-generated-external-id",
 true);
+            businessDateHelper.updateBusinessDate("BUSINESS_DATE", date);
+            action.run();
+        } finally {
+            businessDateHelper.updateBusinessDate("BUSINESS_DATE", todaysDate);
+            
globalConfigHelper.updateConfigurationByName("enable-business-date", false);
+            
globalConfigHelper.updateConfigurationByName("enable-auto-generated-external-id",
 false);
+        }
+    }
+
+    private void advanceBusinessDateAndRunCob(String date) {
+        businessDateHelper.updateBusinessDate("BUSINESS_DATE", date);
+        schedulerHelper.executeAndAwaitJob("Loan COB");
+    }
+
+    private RunReportsResponse runReport(String endDate) {
+        return ok(() -> 
fineractClient().runReports().runReportGetData(REPORT_NAME, Map.of("R_endDate", 
endDate, "R_officeId", "1")));
+    }
+
+    private int findColumnIndex(RunReportsResponse report, String columnName) {
+        List<ResultsetColumnHeaderData> headers = report.getColumnHeaders();
+        for (int i = 0; i < headers.size(); i++) {
+            if (columnName.equals(headers.get(i).getColumnName())) {
+                return i;
+            }
+        }
+        throw new IllegalArgumentException("Column '" + columnName + "' not 
found. Available: "
+                + 
headers.stream().map(ResultsetColumnHeaderData::getColumnName).toList());
+    }
+
+    private BigDecimal parseBigDecimal(Object value) {
+        if (value == null) {
+            return BigDecimal.ZERO;
+        }
+        String str = String.valueOf(value);
+        if (str.isEmpty() || "null".equalsIgnoreCase(str)) {
+            return BigDecimal.ZERO;
+        }
+        return new BigDecimal(str);
+    }
+
+    private Long createClient(String activationDate) {
+        return clientHelper.createClient(new PostClientsRequest()//
+                .officeId(1L)//
+                .legalFormId(1L)//
+                .firstname(Utils.randomFirstNameGenerator())//
+                .lastname(Utils.randomLastNameGenerator())//
+                .externalId(Utils.randomStringGenerator("EXT_", 7))//
+                .active(true)//
+                .activationDate(activationDate)//
+                .dateFormat(LoanTestData.DATETIME_PATTERN)//
+                .locale(LoanTestData.LOCALE));
+    }
+
+    private Long createAndDisburseLoan(Long clientId, String submitDate, 
String disburseDate, Long chargeId) {
+        Long loanProductId = createLoanProduct();
+        assertNotNull(loanProductId);
+
+        PostLoansRequest loanRequest = new PostLoansRequest()//
+                .clientId(clientId)//
+                .productId(loanProductId)//
+                .loanType("individual")//
+                .submittedOnDate(submitDate)//
+                .expectedDisbursementDate(submitDate)//
+                .principal(LOAN_PRINCIPAL)//
+                .loanTermFrequency(4)//
+                .loanTermFrequencyType(2)//
+                .numberOfRepayments(4)//
+                .repaymentEvery(1)//
+                .repaymentFrequencyType(2)//
+                .interestRatePerPeriod(BigDecimal.valueOf(2.0))//
+                .amortizationType(1)//
+                .interestType(0)//
+                .interestCalculationPeriodType(1)//
+                .transactionProcessingStrategyCode("mifos-standard-strategy")//
+                .locale(LoanTestData.LOCALE)//
+                .dateFormat(LoanTestData.DATETIME_PATTERN);
+
+        Long loanId = loanHelper.applyForLoan(loanRequest);
+        assertNotNull(loanId);
+
+        if (chargeId != null) {
+            ok(() -> fineractClient().loanCharges().executeLoanCharge(loanId, 
new PostLoansLoanIdChargesRequest().chargeId(chargeId)
+                    
.amount(500.0).dueDate(disburseDate).dateFormat(LoanTestData.DATETIME_PATTERN).locale(LoanTestData.LOCALE),
+                    (String) null));
+        }
+
+        loanHelper.approveLoan(loanId, new PostLoansLoanIdRequest()//
+                .approvedLoanAmount(LOAN_PRINCIPAL)//
+                .approvedOnDate(submitDate)//
+                .locale(LoanTestData.LOCALE)//
+                .dateFormat(LoanTestData.DATETIME_PATTERN));
+
+        var loanDetails = loanHelper.getLoanDetails(loanId);
+        loanHelper.disburseLoan(loanId, new PostLoansLoanIdRequest()//
+                .actualDisbursementDate(disburseDate)//
+                .transactionAmount(loanDetails.getNetDisbursalAmount())//
+                .locale(LoanTestData.LOCALE)//
+                .dateFormat(LoanTestData.DATETIME_PATTERN));
+
+        return loanId;
+    }
+
+    private Long createFlatFeeCharge(double amount) {
+        return ok(() -> fineractClient().charges().createCharge(new 
ChargeRequest()//
+                .name("TB Fee " + System.currentTimeMillis())//
+                .currencyCode("USD")//
+                .chargeAppliesTo(1)//
+                .chargeTimeType(2)//
+                .chargeCalculationType(1)//
+                .chargePaymentMode(0)//
+                .amount(amount)//
+                .active(true)//
+                .locale(LoanTestData.LOCALE))).getResourceId();
+    }
+
+    private Long createLoanProduct() {
+        return loanHelper.createLoanProduct(new PostLoanProductsRequest()//
+                .name("TrialBal Product " + System.currentTimeMillis())//
+                .shortName(UUID.randomUUID().toString().substring(0, 
4).toUpperCase())//
+                .currencyCode("USD")//
+                .digitsAfterDecimal(2)//
+                .inMultiplesOf(1)//
+                .principal(LOAN_PRINCIPAL.doubleValue())//
+                .numberOfRepayments(4)//
+                .repaymentEvery(1)//
+                .repaymentFrequencyType(2L)//
+                .interestRatePerPeriod(1.0)//
+                .interestRateFrequencyType(2)//
+                .amortizationType(1)//
+                .interestType(0)//
+                .interestCalculationPeriodType(1)//
+                .transactionProcessingStrategyCode("mifos-standard-strategy")//
+                .daysInYearType(365)//
+                .daysInMonthType(30)//
+                .isInterestRecalculationEnabled(false)//
+                .accountingRule(3)//
+                .loanPortfolioAccountId((long) assetAccount.getAccountID())//
+                .transfersInSuspenseAccountId((long) 
assetAccount.getAccountID())//
+                .interestOnLoanAccountId((long) incomeAccount.getAccountID())//
+                .incomeFromFeeAccountId((long) incomeAccount.getAccountID())//
+                .incomeFromPenaltyAccountId((long) 
incomeAccount.getAccountID())//
+                .writeOffAccountId((long) expenseAccount.getAccountID())//
+                .overpaymentLiabilityAccountId((long) 
overpaymentAccount.getAccountID())//
+                .receivableInterestAccountId((long) 
feePenaltyAccount.getAccountID())//
+                .receivableFeeAccountId((long) 
feePenaltyAccount.getAccountID())//
+                .receivablePenaltyAccountId((long) 
feePenaltyAccount.getAccountID())//
+                .fundSourceAccountId((long) assetAccount.getAccountID())//
+                .incomeFromRecoveryAccountId((long) 
incomeAccount.getAccountID())//
+                .locale(LoanTestData.LOCALE)//
+                .dateFormat(LoanTestData.DATETIME_PATTERN));
+    }
+}


Reply via email to