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 55f8380e1..297426a0a 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 @@
+
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 000000000..94bca487e
--- /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 @@
+
+
+
+
+
+
+
+ (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
+
+ ]]>
+
+ report_name='Trial Balance Summary Report with Asset Owner'
+
+
+
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 4dc91104c..163e968e4 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 000000000..0528639f7
--- /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 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 000000000..a33dfcd0b
--- /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 originalBusinessSteps;
+ private List 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 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 expectedColumns = List.of("postingdate", "product", "glacct", "description", "assetowner", "beginningbalance",
+ "debitmovement", "creditmovement", "endingbalance");
+ List 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 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 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 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 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 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));
+ }
+}