diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummary.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummary.java new file mode 100644 index 00000000000..37a39de6e3b --- /dev/null +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummary.java @@ -0,0 +1,63 @@ +/** + * 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.accounting.retainedearning.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.domain.ExternalId; + +/** + * Entity for retained earning summary. + */ +@Getter +@Setter +@Entity +@Table(name = "acc_gl_journal_entry_annual_summary") +public class AccountGLJournalEntryAnnualSummary extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "gl_code") + private String glCode; + + @Column(name = "product_id") + private Long productId; + + @Column(name = "office_id") + private Long officeId; + + @Column(name = "opening_balance_amount") + private BigDecimal openingBalanceAmount; + + @Column(name = "currency_code", nullable = false, length = 3) + private String currencyCode; + + @Column(name = "owner_external_id") + private ExternalId ownerExternalId; + + @Column(name = "manual_entry", nullable = false) + private Boolean manualEntry = false; + + @Column(name = "year_end_date") + private LocalDate yearEndDate; +} diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummaryRepository.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummaryRepository.java new file mode 100644 index 00000000000..56def2281f8 --- /dev/null +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummaryRepository.java @@ -0,0 +1,31 @@ +/** + * 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.accounting.retainedearning.domain; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Repository for retained earning summary entity. + */ +public interface AccountGLJournalEntryAnnualSummaryRepository extends JpaRepository { + + List findByYearEndDate(LocalDate yerEndDate); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java index 5064176d114..457373c3c1f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java @@ -88,6 +88,12 @@ public final class GlobalConfigurationConstants { public static final String FORCE_PASSWORD_RESET_ON_FIRST_LOGIN = "force-password-reset-on-first-login"; public static final String ALLOW_CASH_AND_NON_CASH_ACCRUAL = "allow-cash-and-non-cash-accrual"; public static final String BLOCK_TRANSACTIONS_ON_CLOSED_OVERPAID_LOANS = "block-transactions-on-closed-overpaid-loans"; + public static final String INCOME_EXPENSE_GL_ACCOUNTS = "income-expense-gl-accounts"; + public static final String LAST_DAY_OF_FINANCIAL_YEAR = "last-day-of-financial-year"; + public static final String LAST_MONTH_OF_FINANCIAL_YEAR = "last-month-of-financial-year"; + public static final String RETAINED_EARNING_GL_ACCOUNT = "retained-gl-account"; + public static final String RETAINED_EARNING_USED_BY_REPORT_NAME = "retained-earning-used-by-report-name"; + public static final String OFFICE_ID = "office-id"; private GlobalConfigurationConstants() {} } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java index 342ee34ea99..03e88ddafc8 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java @@ -164,6 +164,18 @@ public interface ConfigurationDomainService { Integer retrieveMaxLoginRetries(); + String getIncomeExpenseGlAccounts(); + + String getRetainedEarningGlAccount(); + + Long getLastDayOfFinancialYear(); + + Long getLastMonthOfFinancialYear(); + + String getRetainedEarningUsedByReportName(); + + Long getOfficeId(); + boolean isAllowCashAndNonCashAccrual(); boolean isBlockTransactionsOnClosedOverpaidLoansEnabled(); diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java index d512c881e05..20b76bdc691 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java @@ -428,6 +428,7 @@ public static class FineractJobProperties { private int stuckRetryThreshold; private boolean loanCobEnabled; private FineractJournalEntryAggregationProperties journalEntryAggregation; + private int retainedEarningChunkSize; } @Getter diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index 51a501599e9..ae356465a11 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -61,6 +61,7 @@ public enum JobName { ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_SAVINGS_WITH_INCOME_POSTED_AS_TRANSACTIONS("Add Accrual Transactions For Savings"), // JOURNAL_ENTRY_AGGREGATION("Journal Entry Aggregation"), // WORKING_CAPITAL_LOAN_COB_JOB("Working Capital Loan COB"), // + RETAINED_EARNING("Retained Earning Job"), // ; // private final String name; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java index bbace62d8f4..d712292c9d5 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java @@ -30,7 +30,8 @@ public enum DefaultJob implements Job { "Add Accrual Transactions For Loans With Income Posted As Transactions", "LA_AATR"), // RECALCULATE_INTEREST_FOR_LOANS("Recalculate Interest For Loans", "LA_RINT"), // WORKING_CAPITAL_LOAN_COB("Working Capital Loan COB", "WC_COB"), // - JOURNAL_ENTRY_AGGREGATION("Journal Entry Aggregation", "JRNL_AGG"); // + JOURNAL_ENTRY_AGGREGATION("Journal Entry Aggregation", "JRNL_AGG"), // + RETAINED_EARNING("Retained Earning Job", "RE_ERNG"); // private final String customName; private final String shortName; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/AnnualSummaryStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/AnnualSummaryStepDef.java new file mode 100644 index 00000000000..82a0f74441a --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/AnnualSummaryStepDef.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.test.stepdef.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.PostOfficesResponse; +import org.apache.fineract.test.stepdef.AbstractStepDef; +import org.apache.fineract.test.support.TestContextKey; +import org.springframework.jdbc.core.JdbcTemplate; + +@RequiredArgsConstructor +public class AnnualSummaryStepDef extends AbstractStepDef { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.ENGLISH); + private static final String OFFICE_ID_CONFIG = "office-id"; + + private final JdbcTemplate testJdbcTemplate; + private final FineractFeignClient fineractClient; + + @Given("any existing year-end retained earnings close for fiscal year ending {string} is removed") + public void removeRetainedEarningsCloseForFiscalYear(final String yearEndDateStr) { + final LocalDate yearEndDate = LocalDate.parse(yearEndDateStr, FORMATTER); + testJdbcTemplate.update("DELETE FROM acc_gl_journal_entry_annual_summary WHERE year_end_date = ?", yearEndDate); + } + + @When("Admin points the Retained Earning Job at the last created office") + public void pointRetainedEarningJobAtLastCreatedOffice() { + // Each scenario runs the close against its own freshly created office so the trial balance only contains + // that scenario's loans - this is what isolates repeated runs from each other. office-id is a numeric + // config, so it is set through the internal configuration API. + final PostOfficesResponse office = testContext().get(TestContextKey.OFFICE_CREATE_RESPONSE); + assertThat(office).as("No office was created. Use 'Admin creates a new office' step first.").isNotNull(); + fineractClient.defaultApi().updateInternalGlobalConfiguration(OFFICE_ID_CONFIG, office.getOfficeId()); + } + + @Then("The journal entry annual summary table contains {int} row(s) for GL code {string} with year end date {string}") + public void annualSummaryTableContainsRows(final int expectedCount, final String glCode, final String yearEndDateStr) { + final LocalDate yearEndDate = LocalDate.parse(yearEndDateStr, FORMATTER); + final PostOfficesResponse office = testContext().get(TestContextKey.OFFICE_CREATE_RESPONSE); + assertThat(office).as("No office was created. Use 'Admin creates a new office' step first.").isNotNull(); + final Integer actualCount = testJdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_journal_entry_annual_summary WHERE gl_code = ? AND year_end_date = ? AND office_id = ?", + Integer.class, glCode, yearEndDate, office.getOfficeId()); + assertThat(actualCount) + .as("acc_gl_journal_entry_annual_summary: expected %d row(s) for gl_code '%s', year_end_date %s, office %s but found %d", + expectedCount, glCode, yearEndDate, office.getOfficeId(), actualCount) + .isEqualTo(expectedCount); + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/SchedulerStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/SchedulerStepDef.java index d1af7270354..8b7ed332731 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/SchedulerStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/SchedulerStepDef.java @@ -93,6 +93,11 @@ public void runWorkingCapitalLoanCOB() { jobService.executeAndWait(DefaultJob.WORKING_CAPITAL_LOAN_COB); } + @When("Admin runs the Retained Earning Job") + public void runRetainedEarning() { + jobService.executeAndWait(DefaultJob.RETAINED_EARNING); + } + @Then("Admin verifies scheduler job {string} has display name {string}") public void verifyJobDisplayName(String shortName, String expectedDisplayName) { GetJobsResponse response = ok(() -> fineractClient.schedulerJob().retrieveByShortName(shortName, Map.of())); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/reporting/ReportingStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/reporting/ReportingStepDef.java index 0825e41c4e2..7d1cebe9878 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/reporting/ReportingStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/reporting/ReportingStepDef.java @@ -43,6 +43,7 @@ public class ReportingStepDef extends AbstractStepDef { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.ENGLISH); + private static final String TRIAL_BALANCE_REPORT = "Trial Balance Summary Report with Asset Owner"; private final FineractFeignClient fineractClient; @Then("Transaction Summary Report for date {string} has the following data:") @@ -77,6 +78,25 @@ public void trialBalanceSummaryReportWithAssetOwnerHasDataWithOwnerExternalIdAnd verifyBalanceReportDataWithOwnerExternalIdAndOriginatorId("Trial Balance Summary Report with Asset Owner", dateStr, dataTable); } + @Then("Trial Balance Summary Report with Asset Owner for date {string} has a row for GL account {string} with non-zero ending balance") + public void trialBalanceHasNonZeroEndingBalanceForGlAccount(final String dateStr, final String glCode) { + final BigDecimal ending = sumColumnForGlAccount(executeReport(TRIAL_BALANCE_REPORT, dateStr), glCode, "endingbalance"); + assertThat(ending).as("Trial Balance for %s: expected GL account '%s' to be present", dateStr, glCode).isNotNull(); + assertThat(ending.signum()) + .as("Trial Balance for %s: expected GL account '%s' ending balance to be non-zero but was %s", dateStr, glCode, ending) + .isNotEqualTo(0); + } + + @Then("Trial Balance Summary Report with Asset Owner for date {string} shows GL account {string} closed out") + public void trialBalanceShowsGlAccountClosedOut(final String dateStr, final String glCode) { + final BigDecimal ending = sumColumnForGlAccount(executeReport(TRIAL_BALANCE_REPORT, dateStr), glCode, "endingbalance"); + if (ending != null) { + assertThat(ending.signum()).as( + "Trial Balance for %s: expected GL account '%s' to be closed out (absent or zero ending balance) but it has ending balance %s", + dateStr, glCode, ending).isEqualTo(0); + } + } + private void verifyReportData(final String reportName, final String dateStr, final DataTable dataTable) { final RunReportsResponse response = executeReport(reportName, dateStr); @@ -283,6 +303,13 @@ private RunReportsResponse executeReport(final String reportName, final String d return response; } + private BigDecimal sumColumnForGlAccount(final RunReportsResponse response, final String glCode, final String columnName) { + final int glIdx = findColumnIndex(response.getColumnHeaders(), "glacct"); + final int colIdx = findColumnIndex(response.getColumnHeaders(), columnName); + return response.getData().stream().filter(r -> r.getRow() != null && glCode.equals(stringify(r.getRow().get(glIdx)))) + .map(r -> new BigDecimal(Objects.toString(r.getRow().get(colIdx), "0"))).reduce(BigDecimal::add).orElse(null); + } + private boolean valuesMatch(final String expected, final String actual) { if (Objects.equals(expected, actual)) { return true; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature new file mode 100644 index 00000000000..d55c7015a8f --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature @@ -0,0 +1,115 @@ +@YearEndRetainedEarning +Feature: Loan Year End Retained Earning + + Background: + When Global config "income-expense-gl-accounts" value set to "400000-899999" + And Global config "retained-gl-account" value set to "320000" + And Global config "retained-earning-used-by-report-name" value set to "Trial Balance Summary Report with Asset Owner" + + @TestRailId:C85187 + Scenario: Verify that year-end close zeroes income accounts into retained earnings, is idempotent and next year starts clean + # --- Isolate this run: dedicated office + clear any prior FY2025 close (the job's idempotency guard is + # global by year-end date), so the suite is repeatable on the same database. --- + Given any existing year-end retained earnings close for fiscal year ending "31 December 2025" is removed + # --- Arrange: a loan recognising interest income (404000) and fee income (404007) in FY2025 --- + When Admin sets the business date to "01 December 2025" + And Admin creates a new office + And Admin points the Retained Earning Job at the last created office + And Admin creates a client with random data in the last created office + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 December 2025 | 100 | 26 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 December 2025" with "100" amount and expected disbursement date on "01 December 2025" + And Admin successfully disburse the loan on "01 December 2025" with "100" EUR transaction amount + And Admin adds "LOAN_SNOOZE_FEE" due date charge with "15 December 2025" due date and 10 EUR transaction amount + When Admin sets the business date to "31 December 2025" + And Admin runs inline COB job for Loan + And Customer makes "AUTOPAY" repayment on "31 December 2025" with 40 EUR transaction amount + # --- Assert BEFORE close: income accounts carry non-zero balances (they accumulate indefinitely) --- + Then Trial Balance Summary Report with Asset Owner for date "31 December 2025" has a row for GL account "404000" with non-zero ending balance + And Trial Balance Summary Report with Asset Owner for date "31 December 2025" has a row for GL account "404007" with non-zero ending balance + # --- Act: enter the new fiscal year and run the year-end close for FY2025 --- + When Admin sets the business date to "02 January 2026" + And Admin runs the Retained Earning Job + # --- Assert AFTER close: both income accounts are zeroed, net carried to retained earnings per owner --- + Then Trial Balance Summary Report with Asset Owner for date "01 January 2026" shows GL account "404000" closed out + And Trial Balance Summary Report with Asset Owner for date "01 January 2026" shows GL account "404007" closed out + And Trial Balance Summary Report with Asset Owner for date "01 January 2026" has a row for GL account "320000" with non-zero ending balance + # --- Out-of-band balance-sheet account is never touched by the close --- + And Trial Balance Summary Report with Asset Owner for date "01 January 2026" has a row for GL account "145023" with non-zero ending balance + # --- Persisted close-out: exactly one retained earnings record (single asset owner: self) --- + And The journal entry annual summary table contains 1 row for GL code "320000" with year end date "31 December 2025" + # --- Idempotency: re-running the job for the same fiscal year does nothing + # Double-posting would break the zero-sum and resurface 404000 with a positive balance. --- + When Admin runs the Retained Earning Job + Then Trial Balance Summary Report with Asset Owner for date "01 January 2026" shows GL account "404000" closed out + And The journal entry annual summary table contains 1 row for GL code "320000" with year end date "31 December 2025" + # --- New-year accounting continues normally: COB posts fresh January accruals, so 404000 reappears + # with only the new period's (small) balance - the closed 2025 total is NOT resurfaced. + When Admin sets the business date to "05 January 2026" + And Admin runs inline COB job for Loan + Then Trial Balance Summary Report with Asset Owner for date "05 January 2026" has a row for GL account "404000" with non-zero ending balance + + @TestRailId:C85188 + Scenario: Verify year-end close zeroes income for loans externalized to an asset owner + # Two loans; the second is sold to an external asset owner. + Given any existing year-end retained earnings close for fiscal year ending "31 December 2026" is removed + When Admin sets the business date to "01 December 2026" + And Admin creates a new office + And Admin points the Retained Earning Job at the last created office + And Admin creates a client with random data in the last created office + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 December 2026 | 100 | 26 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 December 2026" with "100" amount and expected disbursement date on "01 December 2026" + And Admin successfully disburse the loan on "01 December 2026" with "100" EUR transaction amount + And Admin creates a client with random data in the last created office + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 December 2026 | 1000 | 12 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 December 2026" with "1000" amount and expected disbursement date on "01 December 2026" + And Admin successfully disburse the loan on "01 December 2026" with "1000" EUR transaction amount + # --- Sell the second loan to an external asset owner; the sale settles via COB --- + When Admin makes asset externalization request by Loan ID with unique ownerExternalId, user-generated transferExternalId and the following data: + | Transaction type | settlementDate | purchasePriceRatio | + | sale | 2026-12-10 | 1 | + Then Asset externalization response has the correct Loan ID, transferExternalId + When Admin sets the business date to "11 December 2026" + And Admin runs COB job + # --- Accrue December income on both loans --- + When Admin sets the business date to "31 December 2026" + And Admin runs COB job + Then Trial Balance Summary Report with Asset Owner for date "31 December 2026" has a row for GL account "404000" with non-zero ending balance + # --- Close FY2026 --- + When Admin sets the business date to "02 January 2027" + And Admin runs the Retained Earning Job + # --- Income from both loans stays with the originator ("self") and is zeroed into a single retained + # earnings record; the externalized asset is balance-sheet (out of the income/expense band). --- + Then Trial Balance Summary Report with Asset Owner for date "01 January 2027" shows GL account "404000" closed out + And Trial Balance Summary Report with Asset Owner for date "01 January 2027" has a row for GL account "320000" with non-zero ending balance + And The journal entry annual summary table contains 1 row for GL code "320000" with year end date "31 December 2026" + + @TestRailId:C85189 + Scenario: Verify Year-end close keeps GL codes in the trial balance summary report + # A written-off loan posts to "Written off" account whose gl_code is "e4". + Given any existing year-end retained earnings close for fiscal year ending "31 December 2027" is removed + When Admin sets the business date to "01 December 2027" + And Admin creates a new office + And Admin points the Retained Earning Job at the last created office + And Admin creates a client with random data in the last created office + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 December 2027 | 100 | 26 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 December 2027" with "100" amount and expected disbursement date on "01 December 2027" + And Admin successfully disburse the loan on "01 December 2027" with "100" EUR transaction amount + When Admin sets the business date to "15 December 2027" + And Admin runs inline COB job for Loan + And Admin does write-off the loan on "15 December 2027" + # --- Both a numeric income balance AND the alphanumeric expense row are in the trial balance --- + Then Trial Balance Summary Report with Asset Owner for date "31 December 2027" has a row for GL account "404000" with non-zero ending balance + And Trial Balance Summary Report with Asset Owner for date "31 December 2027" has a row for GL account "e4" with non-zero ending balance + # --- Close FY2027: the job must not crash on "e4" and must still close the numeric band --- + When Admin sets the business date to "02 January 2028" + And Admin runs the Retained Earning Job + Then Trial Balance Summary Report with Asset Owner for date "01 January 2028" shows GL account "404000" closed out + And Trial Balance Summary Report with Asset Owner for date "01 January 2028" has a row for GL account "320000" with non-zero ending balance diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductRepository.java index 6a35428a311..d7fb487ec94 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductRepository.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductRepository.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.loanproduct.domain; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucket; @@ -45,4 +46,7 @@ public interface LoanProductRepository extends JpaRepository, @Query("select loanProduct from LoanProduct loanProduct where loanProduct.closeDate is null or loanProduct.closeDate >= :businessDate") List fetchActiveLoanProducts(LocalDate businessDate); + + @Query("select loanProduct from LoanProduct loanProduct where lower(loanProduct.name) in :productNames") + List findAllByNameIgnoreCase(@Param("productNames") Collection productNames); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java index b2cfbae2ecf..a488d6ad508 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java @@ -598,4 +598,45 @@ public boolean isAllowCashAndNonCashAccrual() { public boolean isBlockTransactionsOnClosedOverpaidLoansEnabled() { return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.BLOCK_TRANSACTIONS_ON_CLOSED_OVERPAID_LOANS).isEnabled(); } + + @Override + public String getIncomeExpenseGlAccounts() { + final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( + GlobalConfigurationConstants.INCOME_EXPENSE_GL_ACCOUNTS); + return property.getStringValue(); + } + + @Override + public String getRetainedEarningGlAccount() { + final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( + GlobalConfigurationConstants.RETAINED_EARNING_GL_ACCOUNT); + return property.getStringValue(); + } + + @Override + public Long getLastDayOfFinancialYear() { + final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( + GlobalConfigurationConstants.LAST_DAY_OF_FINANCIAL_YEAR); + return property.getValue(); + } + + @Override + public Long getLastMonthOfFinancialYear() { + final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( + GlobalConfigurationConstants.LAST_MONTH_OF_FINANCIAL_YEAR); + return property.getValue(); + } + + @Override + public String getRetainedEarningUsedByReportName() { + final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( + GlobalConfigurationConstants.RETAINED_EARNING_USED_BY_REPORT_NAME); + return property.getStringValue(); + } + + @Override + public Long getOfficeId() { + final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(GlobalConfigurationConstants.OFFICE_ID); + return property.getValue(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationService.java new file mode 100644 index 00000000000..e534ec309ca --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationService.java @@ -0,0 +1,67 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import static org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningJobConstant.TRIAL_BALANCE_SUMMARY_WITH_ASSET_OWNER; + +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.exception.PlatformInternalServerException; +import org.springframework.stereotype.Component; + +/** + * Job-specific configuration wrapper that isolates retained earning config access from the platform-wide + * {@link ConfigurationDomainService}. Centralizes the fiscal year date calculation used by both the Reader and Writer. + */ +@Component +@RequiredArgsConstructor +public class RetainedEarningConfigurationService { + + private final ConfigurationDomainService configurationDomainService; + + public String getIncomeExpenseGlAccounts() { + return configurationDomainService.getIncomeExpenseGlAccounts(); + } + + public String getRetainedEarningGlAccount() { + return configurationDomainService.getRetainedEarningGlAccount(); + } + + public Long getOfficeId() { + Long value = configurationDomainService.getOfficeId(); + if (value == null) { + throw new PlatformInternalServerException("error.retained.earning.office.id.not.configured", + "Retained earning job office ID is not configured"); + } + return value; + } + + public String getReportName() { + String configured = configurationDomainService.getRetainedEarningUsedByReportName(); + return configured != null ? configured : TRIAL_BALANCE_SUMMARY_WITH_ASSET_OWNER; + } + + public LocalDate getLastDayOfPreviousFiscalYear(LocalDate currentDate) { + final int lastDay = configurationDomainService.getLastDayOfFinancialYear().intValue(); + final int lastMonth = configurationDomainService.getLastMonthOfFinancialYear().intValue(); + LocalDate fiscalEndThisYear = LocalDate.of(currentDate.getYear(), lastMonth, lastDay); + return fiscalEndThisYear.isBefore(currentDate) ? fiscalEndThisYear : fiscalEndThisYear.minusYears(1); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfiguration.java new file mode 100644 index 00000000000..93efe9b2e1c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfiguration.java @@ -0,0 +1,76 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import static org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningJobConstant.JOB_SUMMARY_STEP_NAME; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.listener.RetainedEarningJobListener; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Configuration for Retained earning job + */ +@Configuration +@RequiredArgsConstructor +public class RetainedEarningJobConfiguration { + + private final RetainedEarningJobListener retainedEarningJobListener; + private final JobRepository jobRepository; + private final RetainedEarningJobWriter retainedEarningItemWriter; + private final PlatformTransactionManager transactionManager; + private final FineractProperties fineractProperties; + private final RetainedEarningJobReader retainedEarningJobReader; + + /** + * Step to insert into summary table + * + * @return summary insert step + */ + @Bean + public Step retainedEarningSummaryStep() { + return new StepBuilder(JOB_SUMMARY_STEP_NAME, jobRepository) + .chunk( + fineractProperties.getJob().getRetainedEarningChunkSize(), transactionManager) + .reader(retainedEarningJobReader).writer(retainedEarningItemWriter).allowStartIfComplete(true).build(); + } + + /** + * Retained Earning job with proper data flow between reader, processor, and writer + * + * @return {@link Job} configured job with proper step sequence + */ + @Bean(name = "retainedEarning") + public Job retainedEarning() { + return new JobBuilder(JobName.RETAINED_EARNING.name(), jobRepository).listener(retainedEarningJobListener) + .start(retainedEarningSummaryStep()).incrementer(new RunIdIncrementer()).build(); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConstant.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConstant.java new file mode 100644 index 00000000000..2328893fc1c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConstant.java @@ -0,0 +1,56 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +/** + * Retained Earning job constant + */ +public final class RetainedEarningJobConstant { + + /** + * Constructor + */ + private RetainedEarningJobConstant() {} + + /** + * Retained earning job name + */ + public static final String RETAINED_EARNING_JOB_NAME = "RETAINED_EARNING"; + + /** + * Summary step name + */ + public static final String JOB_SUMMARY_STEP_NAME = "RetainedEarning Summary Insert - Step"; + + /** + * Query parameter - end date + */ + public static final String END_DATE_QUERY_PARAM = "R_endDate"; + + /** + * Report type Trial Balance Summary with asset owner + */ + public static final String TRIAL_BALANCE_SUMMARY_WITH_ASSET_OWNER = "Trial Balance Summary Report with Asset Owner"; + + /** + * Query parameter - office id + */ + public static final String OFFICE_ID_QUERY_PARAM = "R_officeId"; + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReader.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReader.java new file mode 100644 index 00000000000..264a84c1a0b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReader.java @@ -0,0 +1,88 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.services.RetainedEarningDataService; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.stereotype.Component; + +/** + * Spring Batch ItemReader for Retained Earning Job. Fetches trial balance data and delegates processing to the data + * service. Compatible with RetainedEarningJobWriter. + */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class RetainedEarningJobReader implements ItemReader, StepExecutionListener { + + private final RetainedEarningDataService retainedEarningDataService; + private final RetainedEarningConfigurationService retainedEarningConfigurationService; + + private ListItemReader delegate; + + @Override + public void beforeStep(StepExecution stepExecution) { + log.info("Starting RetainedEarningJobReader step at {}", ThreadLocalContextUtil.getBusinessDate()); + } + + @Override + public AccountGLJournalEntryAnnualSummaryData read() throws Exception { + if (delegate == null) { + initialize(); + } + return delegate.read(); + } + + private void initialize() { + try { + final LocalDate currentDate = ThreadLocalContextUtil.getBusinessDateByType(BusinessDateType.BUSINESS_DATE); + final LocalDate lastDayOfPreviousFiscalYear = retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(currentDate); + + log.info("Retained earning job started: businessDate={}, fiscalYearEnd={}, dayOfWeek={}", currentDate, + lastDayOfPreviousFiscalYear, currentDate.getDayOfWeek()); + + List rawData = retainedEarningDataService + .fetchTrialBalanceData(retainedEarningConfigurationService.getReportName(), lastDayOfPreviousFiscalYear); + + log.info("Fetched {} raw records from trial balance for fiscalYearEnd={}", rawData.size(), lastDayOfPreviousFiscalYear); + + final List processedData = retainedEarningDataService.processTrialBalanceData(rawData, + lastDayOfPreviousFiscalYear); + + delegate = new ListItemReader<>(processedData); + log.info("Initialized with {} total records for fiscalYearEnd={}", processedData.size(), lastDayOfPreviousFiscalYear); + + } catch (Exception e) { + log.error("Failed to initialize RetainedEarningJobReader", e); + throw new RuntimeException("Error initializing reader: " + e.getMessage(), e); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriter.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriter.java new file mode 100644 index 00000000000..8f023373132 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriter.java @@ -0,0 +1,93 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummaryRepository; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.services.RetainedEarningDataService; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Retained earning summary item writer. The Reader always runs the full pipeline and logs validation stats daily. This + * Writer persists to the database only if entries do not already exist for the fiscal year end date (idempotency + * guard). This allows the job to be safely rerun on any day if the initial run fails. + */ +@Component +@StepScope +@Slf4j +@RequiredArgsConstructor +public class RetainedEarningJobWriter implements ItemWriter, StepExecutionListener { + + private final RetainedEarningDataService retainedEarningDataService; + private final AccountGLJournalEntryAnnualSummaryRepository annualSummaryRepository; + private final RetainedEarningConfigurationService retainedEarningConfigurationService; + + private boolean shouldWrite; + + @Override + public void beforeStep(StepExecution stepExecution) { + final LocalDate currentDate = ThreadLocalContextUtil.getBusinessDateByType(BusinessDateType.BUSINESS_DATE); + final LocalDate lastDayOfPreviousFiscalYear = retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(currentDate); + final boolean entriesExist = !annualSummaryRepository.findByYearEndDate(lastDayOfPreviousFiscalYear).isEmpty(); + + if (entriesExist) { + shouldWrite = false; + log.info("Retained earning Writer: entries already exist for yearEndDate={}. Will run as dry run.", + lastDayOfPreviousFiscalYear); + } else { + shouldWrite = true; + log.info("Retained earning Writer: no existing entries for yearEndDate={}. Will persist records.", lastDayOfPreviousFiscalYear); + } + } + + @Override + @Transactional + public void write(@NonNull Chunk retainedEarningSummaries) { + List validSummaries = retainedEarningSummaries.getItems().stream().filter(Objects::nonNull) + .collect(Collectors.toList()); + if (validSummaries.isEmpty()) { + log.info("No valid retained earning entries to write"); + return; + } + + if (!shouldWrite) { + log.info("Dry run complete: data pipeline validated successfully, recordsProcessed={}, no records written.", + validSummaries.size()); + return; + } + + retainedEarningDataService.insertRetainedEarningSummaryBatch(validSummaries); + log.info("Year-end processing: persisted {} retained earning records.", validSummaries.size()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/data/AccountGLJournalEntryAnnualSummaryData.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/data/AccountGLJournalEntryAnnualSummaryData.java new file mode 100644 index 00000000000..558683686b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/data/AccountGLJournalEntryAnnualSummaryData.java @@ -0,0 +1,51 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; +import org.apache.fineract.infrastructure.core.domain.ExternalId; + +@Getter +@Builder(toBuilder = true) +public class AccountGLJournalEntryAnnualSummaryData { + + private Long productId; + + private String productName; + + private String glAccountCode; + + private Long officeId; + + private ExternalId ownerExternalId; + + private Boolean manualEntry; + + private BigDecimal openingBalanceAmount; + + private BigDecimal endingBalanceAmount; + + private LocalDate yearEndDate; + + private String currencyCode; + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParser.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParser.java new file mode 100644 index 00000000000..f7d0cab5895 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParser.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.infrastructure.jobs.service.retainedearning.helper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.StreamSupport; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.model.AccountGLJournalEntryAnnualSummaryRecord; +import org.springframework.stereotype.Component; + +@Component +public class DataParser { + + /** + * Object mapper for JSON parsing. + */ + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Parse the JSON string into a list of AccountGLJournalEntryAnnualSummaryRecord. + * + * @param json + * @return + * @throws Exception + */ + public List parse(final String json) throws Exception { + final JsonNode root = objectMapper.readTree(json); + + // Get column names in order + final List columns = new ArrayList<>(); + columns.addAll(StreamSupport.stream(root.path("columnHeaders").spliterator(), false) + .map(header -> header.path("columnName").asText()).collect(Collectors.toList())); + + final List records = StreamSupport.stream(root.path("data").spliterator(), false) + .map(data -> { + JsonNode row = data.path("row"); + + // Create row dataMap Map + Map rowData = IntStream.range(0, Math.min(columns.size(), row.size())).boxed() + .collect(Collectors.toMap(i -> columns.get(i), i -> row.get(i).asText())); + + // Build record + return AccountGLJournalEntryAnnualSummaryRecord.builder().postingDate(rowData.get("postingdate")) + .product(rowData.get("product")).glAcct(rowData.get("glacct")) + .assetOwner(ExternalIdFactory.produce(rowData.get("assetowner"))) + .endingBalance(new BigDecimal(rowData.getOrDefault("endingbalance", "0"))).build(); + }).collect(Collectors.toList()); + + return records; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListener.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListener.java new file mode 100644 index 00000000000..953e682d7a7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListener.java @@ -0,0 +1,89 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.listener; + +import static org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningJobConstant.JOB_SUMMARY_STEP_NAME; +import static org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningJobConstant.RETAINED_EARNING_JOB_NAME; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.stereotype.Component; + +/** + * The job listener + */ +@Component +@Slf4j +public class RetainedEarningJobListener implements JobExecutionListener { + + /** + * {@inheritDoc} + * + * @param jobExecution + * the job execution + */ + @Override + public void beforeJob(JobExecution jobExecution) { + log.info("Starting Retained Earning Job: {}", jobExecution.getJobId()); + } + + /** + * {@inheritDoc} + * + * @param jobExecution + * the job execution + */ + @Override + public void afterJob(JobExecution jobExecution) { + logJobExecutionSummary(jobExecution); + } + + /** + * Method to log the job execution summary + * + * @param jobExecution + * the job execution + */ + private void logJobExecutionSummary(final JobExecution jobExecution) { + final Long jobExecutionId = jobExecution.getId(); + final Long recordProcessCount = jobExecution.getStepExecutions().stream() + .filter(stepExecution -> stepExecution.getStepName().equals(JOB_SUMMARY_STEP_NAME)) + .mapToLong(stepExecution -> stepExecution.getWriteCount()).sum(); + final Instant startDateTime = jobExecution.getStartTime().toInstant(ZoneOffset.UTC); + final Instant endDateTime = jobExecution.getEndTime().toInstant(ZoneOffset.UTC); + Long jobDuration = 0L; + Long startDateTimeMilliSecond = null; + Long endDateTimeMilliSecond = null; + if (startDateTime != null && endDateTime != null) { + startDateTimeMilliSecond = startDateTime.toEpochMilli(); + endDateTimeMilliSecond = endDateTime.toEpochMilli(); + jobDuration = startDateTime.until(endDateTime, ChronoUnit.MINUTES); + } + log.info( + "Execution Summary for jobName={}, totalRecordProcessCount={}, startTime={}, endTime={}, startTime_ms={}, endTime_ms={}, " + + "jobExecutionId={}, jobExecutionDurationInMinutes={}, tenantId={}", + RETAINED_EARNING_JOB_NAME, recordProcessCount, startDateTime, endDateTime, startDateTimeMilliSecond, endDateTimeMilliSecond, + jobExecutionId, jobDuration, ThreadLocalContextUtil.getTenant().getTenantIdentifier()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/model/AccountGLJournalEntryAnnualSummaryRecord.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/model/AccountGLJournalEntryAnnualSummaryRecord.java new file mode 100644 index 00000000000..d733bd3be6a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/model/AccountGLJournalEntryAnnualSummaryRecord.java @@ -0,0 +1,36 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.model; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Getter; +import org.apache.fineract.infrastructure.core.domain.ExternalId; + +@Getter +@Builder +public class AccountGLJournalEntryAnnualSummaryRecord { + + private String postingDate; + private String product; + private String glAcct; + private ExternalId assetOwner; + private BigDecimal endingBalance; + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataService.java new file mode 100644 index 00000000000..f04fa45c600 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataService.java @@ -0,0 +1,34 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.services; + +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; + +public interface RetainedEarningDataService { + + void insertRetainedEarningSummaryBatch(List retainedEarningSummaries); + + List fetchTrialBalanceData(String reportName, LocalDate fiscalYearEnd); + + List processTrialBalanceData(List rawData, + LocalDate lastDayOfPreviousFiscalYear); + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImpl.java new file mode 100644 index 00000000000..9526d9f06ee --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImpl.java @@ -0,0 +1,242 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.services; + +import static org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningJobConstant.END_DATE_QUERY_PARAM; +import static org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningJobConstant.OFFICE_ID_QUERY_PARAM; + +import com.google.common.base.Splitter; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummary; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummaryRepository; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.dataqueries.service.DatatableExportTargetParameter; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningConfigurationService; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.helper.DataParser; +import org.apache.fineract.infrastructure.report.service.ReportingProcessService; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; +import org.glassfish.jersey.internal.util.collection.MultivaluedStringMap; +import org.springframework.stereotype.Component; + +/** + * Retained earning data service implementation. Handles data fetching, processing, and persistence. + */ +@Component +@AllArgsConstructor +@Slf4j +public class RetainedEarningDataServiceImpl implements RetainedEarningDataService { + + private final ReportingProcessService reportingProcessService; + + private final DataParser dataParser; + + private final AccountGLJournalEntryAnnualSummaryRepository retainedEarningSummaryRepository; + + private final LoanProductRepository loanProductRepository; + + private final RetainedEarningConfigurationService retainedEarningConfigurationService; + + private record ProductOwnerKey(String productName, ExternalId ownerExternalId) { + } + + @Override + public void insertRetainedEarningSummaryBatch(final List retainedEarningSummaries) { + if (retainedEarningSummaries == null || retainedEarningSummaries.isEmpty()) { + log.warn("No retained earning summaries provided for insertion, skipping batch save."); + return; + } + List entities = retainedEarningSummaries.stream().map(this::convertToRetainedEarningSummary) + .toList(); + retainedEarningSummaryRepository.saveAll(entities); + } + + private AccountGLJournalEntryAnnualSummary convertToRetainedEarningSummary(final AccountGLJournalEntryAnnualSummaryData summaryDTO) { + AccountGLJournalEntryAnnualSummary entrySummary = new AccountGLJournalEntryAnnualSummary(); + entrySummary.setProductId(summaryDTO.getProductId()); + entrySummary.setGlCode(String.valueOf(summaryDTO.getGlAccountCode())); + entrySummary.setOfficeId(summaryDTO.getOfficeId()); + entrySummary.setOwnerExternalId(summaryDTO.getOwnerExternalId()); + entrySummary.setOpeningBalanceAmount(summaryDTO.getOpeningBalanceAmount()); + entrySummary.setYearEndDate(summaryDTO.getYearEndDate()); + entrySummary.setCurrencyCode(summaryDTO.getCurrencyCode()); + return entrySummary; + } + + @Override + public List fetchTrialBalanceData(String reportName, LocalDate fiscalYearEnd) { + MultivaluedMap queryParams = buildQueryParams(fiscalYearEnd); + Response response = reportingProcessService.processRequest(reportName, queryParams); + if (response.getStatus() != Response.Status.OK.getStatusCode()) { + throw new IllegalStateException("Trial balance report returned HTTP " + response.getStatus() + " for report: " + reportName); + } + String jsonResponse = (String) response.getEntity(); + return parseJsonResponse(jsonResponse); + } + + private MultivaluedMap buildQueryParams(final LocalDate lastDayOfPreviousFiscalYear) { + final MultivaluedMap queryParams = new MultivaluedStringMap(); + queryParams.add(DatatableExportTargetParameter.PRETTY_JSON.getValue(), BooleanUtils.TRUE); + queryParams.add(END_DATE_QUERY_PARAM, lastDayOfPreviousFiscalYear.toString()); + queryParams.add(OFFICE_ID_QUERY_PARAM, String.valueOf(retainedEarningConfigurationService.getOfficeId())); + return queryParams; + } + + @Override + public List processTrialBalanceData(List rawData, + LocalDate lastDayOfPreviousFiscalYear) { + + if (rawData == null || rawData.isEmpty()) { + log.warn("No data to process"); + return Collections.emptyList(); + } + + final String incomeAndExpenseGlAccounts = retainedEarningConfigurationService.getIncomeExpenseGlAccounts(); + final Predicate glAccountMatcher = buildGlAccountMatcher(incomeAndExpenseGlAccounts); + + final List incomeExpenseRecords = rawData.stream().filter( + r -> r != null && r.getGlAccountCode() != null && r.getOwnerExternalId() != null && !r.getOwnerExternalId().isEmpty()) + .filter(r -> glAccountMatcher.test(r.getGlAccountCode())).collect(Collectors.toList()); + + final Set distinctGlCodes = incomeExpenseRecords.stream().map(r -> String.valueOf(r.getGlAccountCode())) + .collect(Collectors.toSet()); + final Set distinctOwners = incomeExpenseRecords.stream().map(AccountGLJournalEntryAnnualSummaryData::getOwnerExternalId) + .collect(Collectors.toSet()); + + log.info( + "Retained earning validation: totalTrialBalanceRecords={}, matchedIncomeExpenseRecords={}, distinctGlAccounts={}, distinctAssetOwners={}, fiscalYearEnd={}", + rawData.size(), incomeExpenseRecords.size(), distinctGlCodes.size(), distinctOwners.size(), lastDayOfPreviousFiscalYear); + + if (incomeExpenseRecords.isEmpty()) { + log.info("No income/expense account records found hence skipping retained earning creation"); + return Collections.emptyList(); + } + + final Set distinctProductNamesLower = incomeExpenseRecords.stream() + .map(AccountGLJournalEntryAnnualSummaryData::getProductName).filter(name -> name != null && !name.isBlank()) + .map(String::toLowerCase).collect(Collectors.toSet()); + final Map productByName = loanProductRepository.findAllByNameIgnoreCase(distinctProductNamesLower).stream() + .collect(Collectors.toMap(p -> p.getName().toLowerCase(), p -> p, (a, b) -> a)); + + final Map retainedByProductAndOwner = incomeExpenseRecords.stream() + .collect(Collectors.toMap(r -> new ProductOwnerKey(r.getProductName(), r.getOwnerExternalId()), + r -> Optional.ofNullable(r.getEndingBalanceAmount()).orElse(BigDecimal.ZERO), BigDecimal::add)); + + final List retainedEarningRecords = createRetainedEarningRecords(retainedByProductAndOwner, + incomeExpenseRecords, lastDayOfPreviousFiscalYear); + + final List allRecords = Stream + .concat(incomeExpenseRecords.stream(), retainedEarningRecords.stream()).map(data -> { + LoanProduct loanProduct = data.getProductName() != null ? productByName.get(data.getProductName().toLowerCase()) : null; + if (loanProduct == null) { + return data; + } + return data.toBuilder().productId(loanProduct.getId()).currencyCode(loanProduct.getCurrency().getCode()).build(); + }).collect(Collectors.toList()); + + log.info( + "Retained earning processing complete: incomeExpenseOffsetRecords={}, retainedEarningRecords={}, totalRecordsToWrite={}, assetOwners={}", + incomeExpenseRecords.size(), retainedEarningRecords.size(), allRecords.size(), distinctOwners); + + return allRecords; + } + + private Predicate buildGlAccountMatcher(String incomeAndExpenseGlAccounts) { + if (incomeAndExpenseGlAccounts == null || incomeAndExpenseGlAccounts.isBlank()) { + return code -> false; + } + List> predicates = new ArrayList<>(); + for (String token : Splitter.on(',').split(incomeAndExpenseGlAccounts)) { + predicates.add(buildPredicate(token)); + } + return predicates.stream().reduce(code -> false, Predicate::or); + } + + private Predicate buildPredicate(String glAccountCode) { + String trimmed = glAccountCode.trim(); + if (trimmed.matches("\\d+-\\d+")) { + String[] bounds = trimmed.split("-", 2); + int from = Integer.parseInt(bounds[0].trim()); + int to = Integer.parseInt(bounds[1].trim()); + return code -> { + try { + int codeInt = Integer.parseInt(code); + return codeInt >= from && codeInt <= to; + } catch (NumberFormatException e) { + return false; + } + }; + } else { + return trimmed::equals; + } + } + + private List createRetainedEarningRecords( + Map retainedByProductAndOwner, List originalData, + LocalDate lastDayOfPreviousFiscalYear) { + + final String retainedEarningGlAccountCode = retainedEarningConfigurationService.getRetainedEarningGlAccount(); + + final Map firstRecordByProductAndOwner = originalData.stream() + .filter(r -> r.getOwnerExternalId() != null && !r.getOwnerExternalId().isEmpty() && r.getProductName() != null) + .collect(Collectors.toMap(r -> new ProductOwnerKey(r.getProductName(), r.getOwnerExternalId()), r -> r, + (first, second) -> first)); + + Long defaultOfficeId = retainedEarningConfigurationService.getOfficeId(); + return retainedByProductAndOwner.entrySet().stream().filter(e -> e.getValue().compareTo(BigDecimal.ZERO) != 0).map(e -> { + final ProductOwnerKey key = e.getKey(); + AccountGLJournalEntryAnnualSummaryData template = firstRecordByProductAndOwner.get(key); + return AccountGLJournalEntryAnnualSummaryData.builder().productName(key.productName()) + .glAccountCode(retainedEarningGlAccountCode).officeId(template != null ? template.getOfficeId() : defaultOfficeId) + .ownerExternalId(key.ownerExternalId()).openingBalanceAmount(e.getValue()).endingBalanceAmount(e.getValue()) + .yearEndDate(lastDayOfPreviousFiscalYear).manualEntry(false).build(); + }).collect(Collectors.toList()); + } + + private List parseJsonResponse(String jsonResponse) { + try { + return dataParser.parse(jsonResponse).stream() + .map(record -> AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode(record.getGlAcct()) + .productName(record.getProduct()).officeId(retainedEarningConfigurationService.getOfficeId()) + .ownerExternalId(record.getAssetOwner()).openingBalanceAmount(record.getEndingBalance().negate()) + .endingBalanceAmount(record.getEndingBalance()).yearEndDate(LocalDate.parse(record.getPostingDate())) + .manualEntry(false).build()) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse trial balance data: " + e.getMessage(), e); + } + } + +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 230310206f8..310f9a8fac5 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -99,6 +99,7 @@ fineract.job.journal-entry-aggregation.exclude-recent-N-days=${FINERACT_JOB_JOUR fineract.job.journal-entry-aggregation.enabled=${FINERACT_JOB_JOURNAL_ENTRY_AGGREGATION_ENABLED:true} #this property if enabled, will create aggregated entry for all data on first run, instead of one entry per submitted_on_date fineract.job.journal-entry-aggregation.chunk-size=${FINERACT_JOB_JOURNAL_ENTRY_AGGREGATION_CHUNK_SIZE:2000} +fineract.job.retainedEarning-chunk-size=${FINERACT_RETAINED_EARNING_CHUNK_SIZE:100} fineract.partitioned-job.partitioned-job-properties[0].job-name=LOAN_COB fineract.partitioned-job.partitioned-job-properties[0].chunk-size=${LOAN_COB_CHUNK_SIZE:100} 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 8e136d43ff9..c603611fb2e 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 @@ -257,4 +257,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0239_add_retained_earning_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0239_add_retained_earning_job.xml new file mode 100644 index 00000000000..11e4283068f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0239_add_retained_earning_job.xml @@ -0,0 +1,50 @@ + + + + + + + select count(1) from job where short_name = 'RE_ERNG' + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0240_add_retained_earning_global_configuration.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0240_add_retained_earning_global_configuration.xml new file mode 100644 index 00000000000..b34d70a02f6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0240_add_retained_earning_global_configuration.xml @@ -0,0 +1,85 @@ + + + + + + + select count(1) from c_configuration where name = 'last-day-of-financial-year' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationServiceTest.java new file mode 100644 index 00000000000..398edc7979a --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationServiceTest.java @@ -0,0 +1,92 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RetainedEarningConfigurationServiceTest { + + @Mock + private ConfigurationDomainService configurationDomainService; + + @InjectMocks + private RetainedEarningConfigurationService retainedEarningConfigurationService; + + @Test + void shouldCalculateLastDayOfPreviousFiscalYear() { + when(configurationDomainService.getLastDayOfFinancialYear()).thenReturn(31L); + when(configurationDomainService.getLastMonthOfFinancialYear()).thenReturn(12L); + + LocalDate result = retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(LocalDate.of(2025, 1, 1)); + + assertEquals(LocalDate.of(2024, 12, 31), result); + } + + @Test + void shouldCalculateFiscalYearEndForSameYearFiscalYearEnd() { + when(configurationDomainService.getLastDayOfFinancialYear()).thenReturn(31L); + when(configurationDomainService.getLastMonthOfFinancialYear()).thenReturn(3L); + + LocalDate result = retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(LocalDate.of(2025, 6, 15)); + + assertEquals(LocalDate.of(2025, 3, 31), result); + } + + @Test + void shouldCalculateFiscalYearEndForNonDecemberFiscalYear() { + when(configurationDomainService.getLastDayOfFinancialYear()).thenReturn(31L); + when(configurationDomainService.getLastMonthOfFinancialYear()).thenReturn(3L); + + LocalDate result = retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(LocalDate.of(2025, 3, 30)); + + assertEquals(LocalDate.of(2024, 3, 31), result); + } + + @Test + void shouldDelegateIncomeExpenseGlAccountStart() { + when(configurationDomainService.getIncomeExpenseGlAccounts()).thenReturn("400000-899999"); + + assertEquals("400000-899999", retainedEarningConfigurationService.getIncomeExpenseGlAccounts()); + } + + @Test + void shouldReturnDefaultReportNameWhenNotConfigured() { + when(configurationDomainService.getRetainedEarningUsedByReportName()).thenReturn(null); + + assertEquals(RetainedEarningJobConstant.TRIAL_BALANCE_SUMMARY_WITH_ASSET_OWNER, + retainedEarningConfigurationService.getReportName()); + } + + @Test + void shouldReturnConfiguredReportName() { + when(configurationDomainService.getRetainedEarningUsedByReportName()).thenReturn("Custom Report"); + + assertEquals("Custom Report", retainedEarningConfigurationService.getReportName()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfigurationTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfigurationTest.java new file mode 100644 index 00000000000..05b707e2748 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfigurationTest.java @@ -0,0 +1,108 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.migration.TenantDataSourceFactory; +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.listener.RetainedEarningJobListener; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.FlowBuilder; +import org.springframework.batch.core.job.flow.Flow; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.transaction.PlatformTransactionManager; + +@ExtendWith(MockitoExtension.class) +class RetainedEarningJobConfigurationTest { + + @Mock + private RetainedEarningJobListener retainedEarningJobListener; + + @Mock + private JobRepository jobRepository; + + @Mock + private RetainedEarningJobWriter retainedEarningJobWriter; + + @Mock + private RetainedEarningJobReader retainedEarningJobReader; + + @Mock + private PlatformTransactionManager transactionManager; + + @Mock + private FineractProperties fineractProperties; + + @Mock + private TenantDataSourceFactory tenantDataSourceFactory; + + @Mock + private FineractProperties.FineractJobProperties jobProperties; + + @Mock + private Step step; + + @Mock + private FlowBuilder flowBuilder; + + @Mock + private Flow flow; + + @InjectMocks + private RetainedEarningJobConfiguration retainedEarningJobConfiguration; + + @BeforeEach + public void setup() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default tenant", "UTC", null)); + // Mock FineractProperties + when(fineractProperties.getJob()).thenReturn(jobProperties); + when(jobProperties.getRetainedEarningChunkSize()).thenReturn(100); + } + + @Test + public void testRetainedEarningSummaryStep() { + // Execute + Step result = retainedEarningJobConfiguration.retainedEarningSummaryStep(); + + // Verify - basic validation that the step configuration is applied + assertNotNull(result); + } + + @Test + public void testRetainedEarning() { + // Execute + Job result = retainedEarningJobConfiguration.retainedEarning(); + // Verify + assertNotNull(result); + assertEquals(JobName.RETAINED_EARNING.name(), result.getName()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReaderTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReaderTest.java new file mode 100644 index 00000000000..04ce2585493 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReaderTest.java @@ -0,0 +1,146 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.services.RetainedEarningDataService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepExecution; + +@ExtendWith(MockitoExtension.class) +class RetainedEarningJobReaderTest { + + @Mock + private RetainedEarningDataService retainedEarningDataService; + + @Mock + private RetainedEarningConfigurationService retainedEarningConfigurationService; + + @InjectMocks + private RetainedEarningJobReader retainedEarningJobReader; + + @Mock + private StepExecution stepExecution; + + private final LocalDate businessDate = LocalDate.now(ZoneId.systemDefault()); + private final LocalDate lastDayOfPreviousFiscalYear = LocalDate.of(businessDate.getYear() - 1, 12, 31); + + @BeforeEach + public void setup() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default tenant", "UTC", null)); + HashMap businessDateMap = new HashMap<>(); + businessDateMap.put(BusinessDateType.COB_DATE, businessDate.minusDays(1)); + businessDateMap.put(BusinessDateType.BUSINESS_DATE, businessDate); + ThreadLocalContextUtil.setBusinessDates(businessDateMap); + + when(retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(businessDate)).thenReturn(lastDayOfPreviousFiscalYear); + when(retainedEarningConfigurationService.getReportName()).thenReturn("test-report"); + } + + @AfterEach + public void tearDown() { + ThreadLocalContextUtil.reset(); + } + + @Test + public void testReadWithEmptyData() throws Exception { + when(retainedEarningDataService.fetchTrialBalanceData(any(), any())).thenReturn(Collections.emptyList()); + when(retainedEarningDataService.processTrialBalanceData(anyList(), eq(lastDayOfPreviousFiscalYear))) + .thenReturn(Collections.emptyList()); + + retainedEarningJobReader.beforeStep(stepExecution); + AccountGLJournalEntryAnnualSummaryData result = retainedEarningJobReader.read(); + + assertNull(result, "Result should be null with empty data"); + verify(retainedEarningDataService).fetchTrialBalanceData(any(), any()); + verify(retainedEarningDataService).processTrialBalanceData(anyList(), eq(lastDayOfPreviousFiscalYear)); + } + + @Test + public void testReadDelegatesToProcessTrialBalanceData() throws Exception { + List rawData = List.of(AccountGLJournalEntryAnnualSummaryData.builder() + .glAccountCode("401001").productName("Test Product").officeId(1L).ownerExternalId(ExternalIdFactory.produce("OWNER1")) + .endingBalanceAmount(BigDecimal.valueOf(1200)).yearEndDate(lastDayOfPreviousFiscalYear).build()); + + List processedData = List.of( + AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode("401001").productId(123L).officeId(1L) + .ownerExternalId(ExternalIdFactory.produce("OWNER1")).endingBalanceAmount(BigDecimal.valueOf(1200)) + .currencyCode("USD").yearEndDate(lastDayOfPreviousFiscalYear).build(), + AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode("320000").productId(123L).officeId(1L) + .ownerExternalId(ExternalIdFactory.produce("OWNER1")).openingBalanceAmount(BigDecimal.valueOf(1200)) + .currencyCode("USD").yearEndDate(lastDayOfPreviousFiscalYear).build()); + + when(retainedEarningDataService.fetchTrialBalanceData(any(), any())).thenReturn(rawData); + when(retainedEarningDataService.processTrialBalanceData(eq(rawData), eq(lastDayOfPreviousFiscalYear))).thenReturn(processedData); + + retainedEarningJobReader.beforeStep(stepExecution); + + int readCount = 0; + AccountGLJournalEntryAnnualSummaryData result; + while ((result = retainedEarningJobReader.read()) != null) { + readCount++; + assertNotNull(result); + } + + assertEquals(2, readCount, "Should read all processed records"); + verify(retainedEarningDataService).processTrialBalanceData(eq(rawData), eq(lastDayOfPreviousFiscalYear)); + } + + @Test + public void testInitializeWithDataProcessingErrors() throws Exception { + when(retainedEarningDataService.fetchTrialBalanceData(any(), any())).thenThrow(new RuntimeException("Test exception")); + + retainedEarningJobReader.beforeStep(stepExecution); + + try { + retainedEarningJobReader.read(); + assertTrue(false, "Should have thrown an exception"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("Test exception")); + } + + verify(retainedEarningDataService).fetchTrialBalanceData(any(), any()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriterTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriterTest.java new file mode 100644 index 00000000000..061e4b98b37 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriterTest.java @@ -0,0 +1,179 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummary; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummaryRepository; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.services.RetainedEarningDataService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.item.Chunk; + +@ExtendWith(MockitoExtension.class) +class RetainedEarningJobWriterTest { + + @Mock + private RetainedEarningDataService retainedEarningDataService; + + @Mock + private AccountGLJournalEntryAnnualSummaryRepository annualSummaryRepository; + + @Mock + private RetainedEarningConfigurationService retainedEarningConfigurationService; + + @InjectMocks + private RetainedEarningJobWriter retainedEarningJobWriter; + + @Mock + private StepExecution stepExecution; + + @BeforeEach + public void setup() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default tenant", "UTC", null)); + } + + private void setBusinessDate(LocalDate date) { + HashMap businessDateMap = new HashMap<>(); + businessDateMap.put(BusinessDateType.COB_DATE, date.minusDays(1)); + businessDateMap.put(BusinessDateType.BUSINESS_DATE, date); + ThreadLocalContextUtil.setBusinessDates(businessDateMap); + } + + private void setupWriteMode(LocalDate businessDate) { + setBusinessDate(businessDate); + LocalDate fiscalEnd = LocalDate.of(businessDate.getYear() - 1, 12, 31); + when(retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(businessDate)).thenReturn(fiscalEnd); + when(annualSummaryRepository.findByYearEndDate(fiscalEnd)).thenReturn(Collections.emptyList()); + retainedEarningJobWriter.beforeStep(stepExecution); + } + + @Test + public void testWriteWithValidItems() { + setupWriteMode(LocalDate.of(LocalDate.now(ZoneId.systemDefault()).getYear(), 1, 1)); + List testItems = createTestData(); + Chunk chunk = new Chunk<>(testItems); + + retainedEarningJobWriter.write(chunk); + + verify(retainedEarningDataService, times(1)).insertRetainedEarningSummaryBatch(testItems); + } + + @Test + public void testWriteWithNullItems() { + setupWriteMode(LocalDate.of(LocalDate.now(ZoneId.systemDefault()).getYear(), 1, 1)); + List testItems = new ArrayList<>(createTestData()); + testItems.add(null); + Chunk chunk = new Chunk<>(testItems); + + retainedEarningJobWriter.write(chunk); + + verify(retainedEarningDataService, times(1)) + .insertRetainedEarningSummaryBatch(testItems.stream().filter(java.util.Objects::nonNull).toList()); + } + + @Test + public void testWriteWithEmptyList() throws Exception { + Chunk chunk = new Chunk<>(Collections.emptyList()); + + retainedEarningJobWriter.write(chunk); + + verify(retainedEarningDataService, never()).insertRetainedEarningSummaryBatch(anyList()); + } + + @Test + public void testWriteWithServiceException() { + setupWriteMode(LocalDate.of(LocalDate.now(ZoneId.systemDefault()).getYear(), 1, 1)); + List testItems = createTestData(); + Chunk chunk = new Chunk<>(testItems); + + doThrow(new RuntimeException("Test exception")).when(retainedEarningDataService).insertRetainedEarningSummaryBatch(anyList()); + + Exception exception = assertThrows(RuntimeException.class, () -> { + retainedEarningJobWriter.write(chunk); + }); + assertEquals("Test exception", exception.getMessage()); + } + + @Test + public void testWritePersistsOnNonJanFirstWhenNoEntriesExist() { + setupWriteMode(LocalDate.of(2026, 1, 3)); + + List testItems = createTestData(); + Chunk chunk = new Chunk<>(testItems); + retainedEarningJobWriter.write(chunk); + + verify(retainedEarningDataService, times(1)).insertRetainedEarningSummaryBatch(testItems); + } + + @Test + public void testWriteSkipsWhenEntriesAlreadyExist() { + LocalDate businessDate = LocalDate.of(LocalDate.now(ZoneId.systemDefault()).getYear(), 1, 1); + setBusinessDate(businessDate); + LocalDate fiscalEnd = LocalDate.of(businessDate.getYear() - 1, 12, 31); + when(retainedEarningConfigurationService.getLastDayOfPreviousFiscalYear(businessDate)).thenReturn(fiscalEnd); + when(annualSummaryRepository.findByYearEndDate(fiscalEnd)).thenReturn(List.of(new AccountGLJournalEntryAnnualSummary())); + retainedEarningJobWriter.beforeStep(stepExecution); + + List testItems = createTestData(); + Chunk chunk = new Chunk<>(testItems); + retainedEarningJobWriter.write(chunk); + + verify(retainedEarningDataService, never()).insertRetainedEarningSummaryBatch(anyList()); + } + + private List createTestData() { + LocalDate yearEndDate = LocalDate.of(2024, 12, 31); + + return Arrays.asList( + AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode("101").productId(123L).officeId(1L) + .ownerExternalId(ExternalIdFactory.produce("OWNER1")).openingBalanceAmount(BigDecimal.valueOf(1000)) + .endingBalanceAmount(BigDecimal.valueOf(1200)).yearEndDate(yearEndDate).build(), + + AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode("102").productId(123L).officeId(1L) + .ownerExternalId(ExternalIdFactory.produce("OWNER1")).openingBalanceAmount(BigDecimal.valueOf(500)) + .endingBalanceAmount(BigDecimal.valueOf(600)).yearEndDate(yearEndDate).build()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningScenarioTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningScenarioTest.java new file mode 100644 index 00000000000..902850eb476 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningScenarioTest.java @@ -0,0 +1,245 @@ +/** + * 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.infrastructure.jobs.service.retainedearning; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummaryRepository; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.helper.DataParser; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.services.RetainedEarningDataServiceImpl; +import org.apache.fineract.infrastructure.report.service.ReportingProcessService; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * End-to-end scenario test that validates retained earning calculation against the sample report from the Confluence + * product requirements page. + * + * Sample data structure from "DE PayIn30 TB 1-1-24.csv": postingdate, product, glacct, description, assetowner, + * beginningbalance, debitmovement, creditmovement, endingbalance + * + * The test simulates: 1. Trial balance data for 12/31/2023 with mixed account types 2. Income/expense accounts + * (400000-899999 range) that need year-end closing 3. Balance sheet accounts (outside range) that should be untouched + * 4. Expected retained earning calculation at GL 320000 + * + * Requirements verified: - Income/expense accounts ending balances sum to retained earnings - Retained earnings GL + * account = 320000 - Balance sheet accounts (e.g., 112601) are excluded from processing - Each asset owner gets their + * own retained earning record - Zero-balance owners don't get retained earning records + */ +@ExtendWith(MockitoExtension.class) +class RetainedEarningScenarioTest { + + @Mock + private ReportingProcessService reportingProcessService; + + @Mock + private DataParser dataParser; + + @Mock + private AccountGLJournalEntryAnnualSummaryRepository retainedEarningSummaryRepository; + + @Mock + private LoanProductRepository loanProductRepository; + + @Mock + private RetainedEarningConfigurationService retainedEarningConfigurationService; + + @InjectMocks + private RetainedEarningDataServiceImpl retainedEarningDataService; + + @Mock + private LoanProduct loanProduct; + + private static final LocalDate FISCAL_YEAR_END = LocalDate.of(2023, 12, 31); + private static final String RETAINED_EARNING_GL = "320000"; + + private void setupConfigMocks() { + when(retainedEarningConfigurationService.getIncomeExpenseGlAccounts()).thenReturn("400000-899999"); + when(retainedEarningConfigurationService.getRetainedEarningGlAccount()).thenReturn(RETAINED_EARNING_GL); + when(loanProductRepository.findAllByNameIgnoreCase(any())).thenReturn(List.of(loanProduct)); + when(loanProduct.getId()).thenReturn(1L); + when(loanProduct.getName()).thenReturn("GPL_DE_PI30"); + when(loanProduct.getCurrency()).thenReturn(new org.apache.fineract.organisation.monetary.domain.MonetaryCurrency("EUR", 2, null)); + } + + /** + * Simulates the actual scenario from the Confluence sample report: - GPL_DE_PI30 product with multiple GL accounts + * - Two asset owners: "10001" (external) and "self" - Mix of balance sheet (112601, 112603) and income/expense + * (401001, 401002, 801001, 801002) accounts - Verifies retained earnings are calculated correctly per owner + */ + @Test + void shouldCalculateRetainedEarningsMatchingSampleReport() { + List trialBalanceData = buildSampleTrialBalanceData(); + setupConfigMocks(); + + List results = retainedEarningDataService.processTrialBalanceData(trialBalanceData, + FISCAL_YEAR_END); + + assertFalse(results.isEmpty(), "Should produce results"); + + List incomeExpenseResults = results.stream() + .filter(r -> !r.getGlAccountCode().equals(RETAINED_EARNING_GL)).toList(); + List retainedEarningResults = results.stream() + .filter(r -> r.getGlAccountCode().equals(RETAINED_EARNING_GL)).toList(); + + // Balance sheet accounts (112601, 112603) are NOT in results + assertTrue(results.stream().noneMatch(r -> r.getGlAccountCode().equals("112601")), + "Balance sheet account 112601 should be excluded"); + assertTrue(results.stream().noneMatch(r -> r.getGlAccountCode().equals("112603")), + "Balance sheet account 112603 should be excluded"); + + assertEquals(4, incomeExpenseResults.size(), "Should have 4 income/expense records"); + assertEquals(2, retainedEarningResults.size(), "Should have 2 retained earning records (one per owner)"); + + // Owner "10001": 401001(-150,000) + 801001(50,000) = -100,000 + AccountGLJournalEntryAnnualSummaryData owner10001RE = retainedEarningResults.stream() + .filter(r -> ExternalIdFactory.produce("10001").equals(r.getOwnerExternalId())).findFirst().orElse(null); + assertNotNull(owner10001RE, "Should have retained earning for owner 10001"); + assertEquals(RETAINED_EARNING_GL, owner10001RE.getGlAccountCode()); + assertEquals(0, new BigDecimal("-100000.00").compareTo(owner10001RE.getOpeningBalanceAmount()), + "Owner 10001 retained earning should be -100,000.00"); + assertEquals(FISCAL_YEAR_END, owner10001RE.getYearEndDate()); + assertFalse(owner10001RE.getManualEntry()); + + // Owner "self": 401002(-200,000) + 801002(75,000) = -125,000 + AccountGLJournalEntryAnnualSummaryData ownerSelfRE = retainedEarningResults.stream() + .filter(r -> ExternalIdFactory.produce("self").equals(r.getOwnerExternalId())).findFirst().orElse(null); + assertNotNull(ownerSelfRE, "Should have retained earning for owner self"); + assertEquals(RETAINED_EARNING_GL, ownerSelfRE.getGlAccountCode()); + assertEquals(0, new BigDecimal("-125000.00").compareTo(ownerSelfRE.getOpeningBalanceAmount()), + "Owner self retained earning should be -125,000.00"); + + // All records have product info populated + for (AccountGLJournalEntryAnnualSummaryData result : results) { + assertEquals(1L, result.getProductId(), "All records should have product ID set"); + assertEquals("EUR", result.getCurrencyCode(), "All records should have currency code set"); + } + } + + @Test + void shouldNotCreateRetainedEarningWhenNetBalanceIsZero() { + List trialBalanceData = new ArrayList<>(); + trialBalanceData.add(buildRecord("401001", "10001", new BigDecimal("-500.00"))); + trialBalanceData.add(buildRecord("801001", "10001", new BigDecimal("500.00"))); + + setupConfigMocks(); + + List results = retainedEarningDataService.processTrialBalanceData(trialBalanceData, + FISCAL_YEAR_END); + + assertEquals(2, results.size(), "Should only have income/expense records, no retained earnings"); + assertTrue(results.stream().noneMatch(rec -> rec.getGlAccountCode().equals(RETAINED_EARNING_GL)), + "No retained earning record should be created for zero net balance"); + } + + @Test + void shouldParseTrialBalanceReportJsonFormat() throws Exception { + DataParser parser = new DataParser(); + + String reportJson = """ + { + "columnHeaders": [ + {"columnName": "postingdate", "columnType": "DATE"}, + {"columnName": "product", "columnType": "VARCHAR"}, + {"columnName": "glacct", "columnType": "VARCHAR"}, + {"columnName": "description", "columnType": "VARCHAR"}, + {"columnName": "assetowner", "columnType": "VARCHAR"}, + {"columnName": "beginningbalance", "columnType": "DECIMAL"}, + {"columnName": "debitmovement", "columnType": "DECIMAL"}, + {"columnName": "creditmovement", "columnType": "DECIMAL"}, + {"columnName": "endingbalance", "columnType": "DECIMAL"} + ], + "data": [ + {"row": ["2023-12-31", "GPL_DE_PI30", "112601", "Loans Receivable", "10001", "467059174.32", "8006.88", "-15241427.80", "451825753.40"]}, + {"row": ["2023-12-31", "GPL_DE_PI30", "401001", "Fee Income", "10001", "0.00", "1000.00", "-151000.00", "-150000.00"]}, + {"row": ["2023-12-31", "GPL_DE_PI30", "801001", "Interest Expense", "10001", "0.00", "55000.00", "-5000.00", "50000.00"]}, + {"row": ["2023-12-31", "GPL_DE_PI30", "401002", "Service Fee Income", "self", "0.00", "500.00", "-200500.00", "-200000.00"]}, + {"row": ["2023-12-31", "GPL_DE_PI30", "801002", "Operating Expense", "self", "0.00", "80000.00", "-5000.00", "75000.00"]} + ] + } + """; + + var records = parser.parse(reportJson); + + assertEquals(5, records.size()); + assertEquals("2023-12-31", records.get(0).getPostingDate()); + assertEquals("GPL_DE_PI30", records.get(0).getProduct()); + assertEquals("112601", records.get(0).getGlAcct()); + assertEquals(ExternalIdFactory.produce("10001"), records.get(0).getAssetOwner()); + assertEquals(new BigDecimal("451825753.40"), records.get(0).getEndingBalance()); + assertEquals("401001", records.get(1).getGlAcct()); + assertEquals(new BigDecimal("-150000.00"), records.get(1).getEndingBalance()); + assertEquals("801001", records.get(2).getGlAcct()); + assertEquals(new BigDecimal("50000.00"), records.get(2).getEndingBalance()); + } + + @Test + void shouldMapRetainedEarningToEntityCorrectly() { + AccountGLJournalEntryAnnualSummaryData retainedEarning = AccountGLJournalEntryAnnualSummaryData.builder() + .glAccountCode(RETAINED_EARNING_GL).productId(1L).productName("GPL_DE_PI30").officeId(1L) + .ownerExternalId(ExternalIdFactory.produce("10001")).openingBalanceAmount(new BigDecimal("-100000.00")) + .endingBalanceAmount(new BigDecimal("-100000.00")).yearEndDate(FISCAL_YEAR_END).currencyCode("EUR").manualEntry(false) + .build(); + + assertEquals(RETAINED_EARNING_GL, retainedEarning.getGlAccountCode()); + assertEquals(1L, retainedEarning.getProductId()); + assertEquals("GPL_DE_PI30", retainedEarning.getProductName()); + assertEquals(1L, retainedEarning.getOfficeId()); + assertEquals(ExternalIdFactory.produce("10001"), retainedEarning.getOwnerExternalId()); + assertEquals(new BigDecimal("-100000.00"), retainedEarning.getOpeningBalanceAmount()); + assertEquals(new BigDecimal("-100000.00"), retainedEarning.getEndingBalanceAmount()); + assertEquals(FISCAL_YEAR_END, retainedEarning.getYearEndDate()); + assertEquals("EUR", retainedEarning.getCurrencyCode()); + assertFalse(retainedEarning.getManualEntry()); + } + + private List buildSampleTrialBalanceData() { + List data = new ArrayList<>(); + data.add(buildRecord("112601", "10001", new BigDecimal("451825753.40"))); + data.add(buildRecord("112601", "self", new BigDecimal("247629764.29"))); + data.add(buildRecord("112603", "10001", new BigDecimal("5000000.00"))); + data.add(buildRecord("401001", "10001", new BigDecimal("-150000.00"))); + data.add(buildRecord("401002", "self", new BigDecimal("-200000.00"))); + data.add(buildRecord("801001", "10001", new BigDecimal("50000.00"))); + data.add(buildRecord("801002", "self", new BigDecimal("75000.00"))); + return data; + } + + private AccountGLJournalEntryAnnualSummaryData buildRecord(String glAccountId, String ownerExternalId, BigDecimal endingBalance) { + return AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode(glAccountId).productName("GPL_DE_PI30").officeId(1L) + .ownerExternalId(ExternalIdFactory.produce(ownerExternalId)).openingBalanceAmount(BigDecimal.ZERO) + .endingBalanceAmount(endingBalance).yearEndDate(FISCAL_YEAR_END).manualEntry(false).build(); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParserTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParserTest.java new file mode 100644 index 00000000000..117028021e5 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParserTest.java @@ -0,0 +1,201 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.model.AccountGLJournalEntryAnnualSummaryRecord; +import org.junit.jupiter.api.Test; + +class DataParserTest { + + private final DataParser parser = new DataParser(); + + @Test + void shouldParseValidJsonWithMultipleRows() throws Exception { + // use class-level parser instance + String json = """ + { + "columnHeaders": [ + {"columnName": "postingdate"}, + {"columnName": "product"}, + {"columnName": "glacct"}, + {"columnName": "description"}, + {"columnName": "assetowner"}, + {"columnName": "beginningbalance"}, + {"columnName": "endingbalance"} + ], + "data": [ + {"row": ["2024-12-31", "DE PAYIN30", "400001", "Fee Income", "OWNER1", "1000.50", "1200.75"]}, + {"row": ["2024-12-31", "DE PAYIN30", "500001", "Interest Expense", "OWNER2", "-500.00", "-300.25"]} + ] + } + """; + + List records = parser.parse(json); + + assertEquals(2, records.size()); + + AccountGLJournalEntryAnnualSummaryRecord first = records.get(0); + assertEquals("2024-12-31", first.getPostingDate()); + assertEquals("DE PAYIN30", first.getProduct()); + assertEquals("400001", first.getGlAcct()); + assertEquals(ExternalIdFactory.produce("OWNER1"), first.getAssetOwner()); + assertEquals(new BigDecimal("1200.75"), first.getEndingBalance()); + + AccountGLJournalEntryAnnualSummaryRecord second = records.get(1); + assertEquals(ExternalIdFactory.produce("OWNER2"), second.getAssetOwner()); + assertEquals(new BigDecimal("-300.25"), second.getEndingBalance()); + } + + @Test + void shouldReturnEmptyListForEmptyDataArray() throws Exception { + // use class-level parser instance + String json = """ + { + "columnHeaders": [ + {"columnName": "postingdate"}, + {"columnName": "glacct"} + ], + "data": [] + } + """; + + List records = parser.parse(json); + + assertNotNull(records); + assertTrue(records.isEmpty()); + } + + @Test + void shouldHandleMissingOptionalColumns() throws Exception { + // use class-level parser instance + String json = """ + { + "columnHeaders": [ + {"columnName": "postingdate"}, + {"columnName": "product"}, + {"columnName": "glacct"}, + {"columnName": "description"}, + {"columnName": "assetowner"} + ], + "data": [ + {"row": ["2024-12-31", "TestProduct", "400001", "Fee Income", "OWNER1"]} + ] + } + """; + + List records = parser.parse(json); + + assertEquals(1, records.size()); + assertEquals(new BigDecimal("0"), records.get(0).getEndingBalance()); + } + + @Test + void shouldThrowExceptionForMalformedJson() throws Exception { + // use class-level parser instance + String malformedJson = "{ this is not valid json }"; + + assertThrows(Exception.class, () -> parser.parse(malformedJson)); + } + + @Test + void shouldThrowExceptionForNullInput() throws Exception { + // use class-level parser instance + + assertThrows(Exception.class, () -> parser.parse(null)); + } + + @Test + void shouldHandleRowWithFewerColumnsThanHeaders() throws Exception { + // use class-level parser instance + String json = """ + { + "columnHeaders": [ + {"columnName": "postingdate"}, + {"columnName": "product"}, + {"columnName": "glacct"}, + {"columnName": "description"}, + {"columnName": "assetowner"}, + {"columnName": "beginningbalance"}, + {"columnName": "endingbalance"} + ], + "data": [ + {"row": ["2024-12-31", "TestProduct", "400001"]} + ] + } + """; + + List records = parser.parse(json); + + assertEquals(1, records.size()); + assertEquals("2024-12-31", records.get(0).getPostingDate()); + assertEquals("TestProduct", records.get(0).getProduct()); + assertEquals("400001", records.get(0).getGlAcct()); + } + + @Test + void shouldHandleMissingColumnHeadersAndDataPaths() throws Exception { + // use class-level parser instance + String json = """ + { + "someOtherField": "value" + } + """; + + List records = parser.parse(json); + + assertNotNull(records); + assertTrue(records.isEmpty()); + } + + @Test + void shouldHandleNegativeAndZeroBalances() throws Exception { + // use class-level parser instance + String json = """ + { + "columnHeaders": [ + {"columnName": "postingdate"}, + {"columnName": "product"}, + {"columnName": "glacct"}, + {"columnName": "description"}, + {"columnName": "assetowner"}, + {"columnName": "beginningbalance"}, + {"columnName": "endingbalance"} + ], + "data": [ + {"row": ["2024-12-31", "TestProduct", "400001", "Fee Income", "OWNER1", "0", "0"]}, + {"row": ["2024-12-31", "TestProduct", "400002", "Interest", "OWNER1", "-100.50", "-200.75"]} + ] + } + """; + + List records = parser.parse(json); + + assertEquals(2, records.size()); + assertEquals(BigDecimal.ZERO, records.get(0).getEndingBalance()); + assertEquals(new BigDecimal("-200.75"), records.get(1).getEndingBalance()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListenerTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListenerTest.java new file mode 100644 index 00000000000..c2b9892119b --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListenerTest.java @@ -0,0 +1,86 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.listener; + +import static org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningJobConstant.JOB_SUMMARY_STEP_NAME; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashSet; +import java.util.Set; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.StepExecution; + +@ExtendWith(MockitoExtension.class) +class RetainedEarningJobListenerTest { + + @InjectMocks + private RetainedEarningJobListener retainedEarningJobListener; + + @Mock + private JobExecution jobExecution; + + @BeforeEach + public void setup() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default tenant", "UTC", null)); + } + + @Test + public void testBeforeJob() { + // beforeJob should complete without exceptions and log the job ID + retainedEarningJobListener.beforeJob(jobExecution); + verify(jobExecution).getJobId(); + } + + @Test + public void testAfterJob() { + // Mock start and end times + LocalDateTime startTime = LocalDateTime.now(ZoneId.systemDefault()).minusMinutes(10); + LocalDateTime endTime = LocalDateTime.now(ZoneId.systemDefault()); + + when(jobExecution.getStartTime()).thenReturn(startTime); + when(jobExecution.getEndTime()).thenReturn(endTime); + + StepExecution stepExecution = mock(StepExecution.class); + when(stepExecution.getStepName()).thenReturn(JOB_SUMMARY_STEP_NAME); + when(stepExecution.getWriteCount()).thenReturn(100L); + + Set stepExecutions = new HashSet<>(); + stepExecutions.add(stepExecution); + when(jobExecution.getStepExecutions()).thenReturn(stepExecutions); + + // This also primarily logs a message, we can test that it completes without exceptions + retainedEarningJobListener.afterJob(jobExecution); + verify(jobExecution).getId(); + verify(jobExecution).getStepExecutions(); + verify(jobExecution).getStartTime(); + verify(jobExecution).getEndTime(); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImplTest.java new file mode 100644 index 00000000000..d3b15a2e143 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImplTest.java @@ -0,0 +1,222 @@ +/** + * 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.infrastructure.jobs.service.retainedearning.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummary; +import org.apache.fineract.accounting.retainedearning.domain.AccountGLJournalEntryAnnualSummaryRepository; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.RetainedEarningConfigurationService; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.data.AccountGLJournalEntryAnnualSummaryData; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.helper.DataParser; +import org.apache.fineract.infrastructure.jobs.service.retainedearning.model.AccountGLJournalEntryAnnualSummaryRecord; +import org.apache.fineract.infrastructure.report.service.ReportingProcessService; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RetainedEarningDataServiceImplTest { + + @Mock + private ReportingProcessService reportingProcessService; + + @Mock + private DataParser dataParser; + + @Mock + private AccountGLJournalEntryAnnualSummaryRepository retainedEarningSummaryRepository; + + @Mock + private LoanProductRepository loanProductRepository; + + @Mock + private RetainedEarningConfigurationService retainedEarningConfigurationService; + + @InjectMocks + private RetainedEarningDataServiceImpl retainedEarningDataService; + + @Test + void shouldInsertBatchAndMapAllFieldsCorrectly() { + LocalDate yearEndDate = LocalDate.of(2024, 12, 31); + List summaries = List.of(AccountGLJournalEntryAnnualSummaryData.builder() + .glAccountCode("400001").productId(10L).officeId(1L).ownerExternalId(ExternalIdFactory.produce("OWNER1")) + .openingBalanceAmount(new BigDecimal("1000.50")).yearEndDate(yearEndDate).currencyCode("USD").manualEntry(false).build()); + + retainedEarningDataService.insertRetainedEarningSummaryBatch(summaries); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(retainedEarningSummaryRepository).saveAll(captor.capture()); + + List savedEntities = captor.getValue(); + assertEquals(1, savedEntities.size()); + + AccountGLJournalEntryAnnualSummary entity = savedEntities.get(0); + assertEquals("400001", entity.getGlCode()); + assertEquals(10L, entity.getProductId()); + assertEquals(1L, entity.getOfficeId()); + assertEquals(ExternalIdFactory.produce("OWNER1"), entity.getOwnerExternalId()); + assertEquals(new BigDecimal("1000.50"), entity.getOpeningBalanceAmount()); + assertEquals(yearEndDate, entity.getYearEndDate()); + assertEquals("USD", entity.getCurrencyCode()); + } + + @Test + void shouldInsertMultipleRecordsInBatch() { + LocalDate yearEndDate = LocalDate.of(2024, 12, 31); + List summaries = Arrays.asList( + AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode("400001").productId(10L).officeId(1L) + .ownerExternalId(ExternalIdFactory.produce("OWNER1")).openingBalanceAmount(BigDecimal.valueOf(1000)) + .yearEndDate(yearEndDate).currencyCode("USD").build(), + AccountGLJournalEntryAnnualSummaryData.builder().glAccountCode("500001").productId(10L).officeId(1L) + .ownerExternalId(ExternalIdFactory.produce("OWNER2")).openingBalanceAmount(BigDecimal.valueOf(2000)) + .yearEndDate(yearEndDate).currencyCode("EUR").build()); + + retainedEarningDataService.insertRetainedEarningSummaryBatch(summaries); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(retainedEarningSummaryRepository).saveAll(captor.capture()); + assertEquals(2, captor.getValue().size()); + } + + @Test + void shouldSkipSaveWhenEmptyBatch() { + retainedEarningDataService.insertRetainedEarningSummaryBatch(List.of()); + verifyNoInteractions(retainedEarningSummaryRepository); + } + + @Test + void shouldSkipSaveWhenNullBatch() { + retainedEarningDataService.insertRetainedEarningSummaryBatch(null); + verifyNoInteractions(retainedEarningSummaryRepository); + } + + @Test + void shouldFetchTrialBalanceDataAndParseResponse() throws Exception { + String reportName = "Trial Balance Summary Report with Asset Owner"; + LocalDate fiscalYearEnd = LocalDate.of(2024, 12, 31); + + Response mockResponse = mockOkResponse("{\"columnHeaders\":[], \"data\":[]}"); + when(reportingProcessService.processRequest(eq(reportName), any())).thenReturn(mockResponse); + when(dataParser.parse(anyString())).thenReturn(List.of()); + + List result = retainedEarningDataService.fetchTrialBalanceData(reportName, fiscalYearEnd); + + assertNotNull(result); + assertTrue(result.isEmpty()); + verify(reportingProcessService).processRequest(eq(reportName), any()); + } + + @Test + void shouldFetchTrialBalanceDataAndMapRecords() throws Exception { + String reportName = "Test Report"; + LocalDate fiscalYearEnd = LocalDate.of(2024, 12, 31); + + Response mockResponse = mockOkResponse("json-content"); + when(reportingProcessService.processRequest(eq(reportName), any())).thenReturn(mockResponse); + + List parsedRecords = List + .of(AccountGLJournalEntryAnnualSummaryRecord.builder().postingDate("2024-12-31").product("TestProduct").glAcct("400001") + .assetOwner(ExternalIdFactory.produce("OWNER1")).endingBalance(BigDecimal.valueOf(1200)).build()); + + when(retainedEarningConfigurationService.getOfficeId()).thenReturn(1L); + when(dataParser.parse(anyString())).thenReturn(parsedRecords); + + List result = retainedEarningDataService.fetchTrialBalanceData(reportName, fiscalYearEnd); + + assertEquals(1, result.size()); + AccountGLJournalEntryAnnualSummaryData data = result.getFirst(); + assertEquals("400001", data.getGlAccountCode()); + assertEquals("TestProduct", data.getProductName()); + assertEquals(1L, data.getOfficeId()); + assertEquals(ExternalIdFactory.produce("OWNER1"), data.getOwnerExternalId()); + assertEquals(new BigDecimal("1200").negate(), data.getOpeningBalanceAmount()); + assertEquals(new BigDecimal("1200"), data.getEndingBalanceAmount()); + assertEquals(LocalDate.of(2024, 12, 31), data.getYearEndDate()); + assertFalse(data.getManualEntry()); + } + + @Test + void shouldThrowExceptionWhenParsingFails() throws Exception { + String reportName = "Test Report"; + LocalDate fiscalYearEnd = LocalDate.of(2024, 12, 31); + + Response mockResponse = mockOkResponse("invalid-json"); + when(reportingProcessService.processRequest(eq(reportName), any())).thenReturn(mockResponse); + when(dataParser.parse(anyString())).thenThrow(new RuntimeException("Parse error")); + + assertThrows(IllegalArgumentException.class, () -> retainedEarningDataService.fetchTrialBalanceData(reportName, fiscalYearEnd)); + } + + @Test + void shouldThrowExceptionWhenResponseIsNotOk() { + String reportName = "Test Report"; + LocalDate fiscalYearEnd = LocalDate.of(2024, 12, 31); + + Response mockResponse = Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + when(reportingProcessService.processRequest(eq(reportName), any())).thenReturn(mockResponse); + + assertThrows(IllegalStateException.class, () -> retainedEarningDataService.fetchTrialBalanceData(reportName, fiscalYearEnd)); + } + + @Test + void shouldMapNullCurrencyCodeWithoutError() { + List summaries = List.of(AccountGLJournalEntryAnnualSummaryData.builder() + .glAccountCode("400001").productId(10L).officeId(1L).ownerExternalId(ExternalIdFactory.produce("OWNER1")) + .openingBalanceAmount(BigDecimal.ZERO).yearEndDate(LocalDate.of(2024, 12, 31)).currencyCode(null).build()); + + retainedEarningDataService.insertRetainedEarningSummaryBatch(summaries); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(retainedEarningSummaryRepository).saveAll(captor.capture()); + + AccountGLJournalEntryAnnualSummary entity = captor.getValue().get(0); + assertEquals(null, entity.getCurrencyCode()); + } + + private Response mockOkResponse(String body) { + Response response = org.mockito.Mockito.mock(Response.class); + when(response.getStatus()).thenReturn(Response.Status.OK.getStatusCode()); + when(response.getEntity()).thenReturn(body); + return response; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java index f00f54fa03a..734a170a24b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java @@ -649,6 +649,51 @@ private static ArrayList getAllDefaultGlobalConfigurations() { enableInstantDelinquencyCalculation.put("trapDoor", false); defaults.add(enableInstantDelinquencyCalculation); + HashMap lastDayOfFinancialYear = new HashMap<>(); + lastDayOfFinancialYear.put("name", GlobalConfigurationConstants.LAST_DAY_OF_FINANCIAL_YEAR); + lastDayOfFinancialYear.put("value", 31L); + lastDayOfFinancialYear.put("enabled", true); + lastDayOfFinancialYear.put("trapDoor", false); + defaults.add(lastDayOfFinancialYear); + + HashMap lastMonthOfFinancialYear = new HashMap<>(); + lastMonthOfFinancialYear.put("name", GlobalConfigurationConstants.LAST_MONTH_OF_FINANCIAL_YEAR); + lastMonthOfFinancialYear.put("value", 12L); + lastMonthOfFinancialYear.put("enabled", true); + lastMonthOfFinancialYear.put("trapDoor", false); + defaults.add(lastMonthOfFinancialYear); + + HashMap incomeExpenseGlAccounts = new HashMap<>(); + incomeExpenseGlAccounts.put("name", GlobalConfigurationConstants.INCOME_EXPENSE_GL_ACCOUNTS); + incomeExpenseGlAccounts.put("value", 0L); + incomeExpenseGlAccounts.put("enabled", true); + incomeExpenseGlAccounts.put("trapDoor", false); + incomeExpenseGlAccounts.put("string_value", ""); + defaults.add(incomeExpenseGlAccounts); + + HashMap retainedEarningGlAccount = new HashMap<>(); + retainedEarningGlAccount.put("name", GlobalConfigurationConstants.RETAINED_EARNING_GL_ACCOUNT); + retainedEarningGlAccount.put("value", 0L); + retainedEarningGlAccount.put("enabled", true); + retainedEarningGlAccount.put("trapDoor", false); + retainedEarningGlAccount.put("string_value", ""); + defaults.add(retainedEarningGlAccount); + + HashMap officeId = new HashMap<>(); + officeId.put("name", GlobalConfigurationConstants.OFFICE_ID); + officeId.put("value", 1L); + officeId.put("enabled", true); + officeId.put("trapDoor", false); + defaults.add(officeId); + + HashMap retainedEarningUsedByReportName = new HashMap<>(); + retainedEarningUsedByReportName.put("name", GlobalConfigurationConstants.RETAINED_EARNING_USED_BY_REPORT_NAME); + retainedEarningUsedByReportName.put("value", 0L); + retainedEarningUsedByReportName.put("enabled", true); + retainedEarningUsedByReportName.put("trapDoor", false); + retainedEarningUsedByReportName.put("string_value", "Trial Balance Summary Report with Asset Owner"); + defaults.add(retainedEarningUsedByReportName); + return defaults; }