FINERACT-2466: fix trial balance report

Signed-off-by: Attila Budai <budai.attila.istvan@gmail.com>
This commit is contained in:
Attila Budai
2026-02-18 10:17:17 +00:00
committed by Adam Saghy
parent 9ebaf46b6a
commit 115063b89e
5 changed files with 750 additions and 0 deletions
@@ -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>
@@ -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>
@@ -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;
@@ -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());
});
}
}
@@ -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));
}
}