From 20491bf957d728f866e06d08ab13fd7a249d5a54 Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Thu, 11 Jun 2026 17:58:38 +0200 Subject: [PATCH 1/3] FINERACT-1257: added e2e test for validation of year-end close zeroes income accounts into retained earnings --- .../fineract/test/data/job/DefaultJob.java | 3 +- .../test/stepdef/common/SchedulerStepDef.java | 5 + .../stepdef/reporting/ReportingStepDef.java | 36 ++++++ .../LoanYearEndRetainedEarning.feature | 106 ++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature 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 347f3fca3a0..2cf9ec6c841 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 @@ -29,7 +29,8 @@ public enum DefaultJob implements Job { ADD_ACCRUAL_TRANSACTIONS_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "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"); // + WORKING_CAPITAL_LOAN_COB("Working Capital Loan COB", "WC_COB"), // + 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/SchedulerStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/SchedulerStepDef.java index 5b19f9ac15c..7e9d89d8955 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 @@ -87,6 +87,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 c86c94ff9d1..a2219a62fdd 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,8 @@ 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 static final String HEAD_OFFICE_ID = "1"; private final FineractFeignClient fineractClient; @Then("Transaction Summary Report for date {string} has the following data:") @@ -65,6 +67,25 @@ public void transactionSummaryReportWithAssetOwnerColumnEmpty(final String dateS verifyColumnNullability("Transaction Summary Report with Asset Owner", dateStr, columnName, true); } + @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(runTrialBalanceForHeadOffice(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(runTrialBalanceForHeadOffice(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); @@ -135,6 +156,21 @@ private RunReportsResponse executeReport(final String reportName, final String d return response; } + private RunReportsResponse runTrialBalanceForHeadOffice(final String dateStr) { + final String date = LocalDate.parse(dateStr, FORMATTER).toString(); + final RunReportsResponse response = fineractClient.runReports().runReportGetData(TRIAL_BALANCE_REPORT, + Map.of("R_endDate", date, "R_officeId", HEAD_OFFICE_ID, "locale", "en", "dateFormat", "yyyy-MM-dd")); + assertThat(response.getData()).as("Report '%s' returned no data", TRIAL_BALANCE_REPORT).isNotNull(); + 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..c78b2d0c3a1 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature @@ -0,0 +1,106 @@ +@YearEndRetainedEarning +Feature: Loan Year End Retained Earning + + Background: + # Calendar fiscal year + income/expense band + close-out target. + When Global config "last-day-of-financial-year" value set to "31" + And Global config "last-month-of-financial-year" value set to "12" + And 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 + # --- 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 client with random data + 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 handles multiple loan products and multiple asset owners + # Two loans on DIFFERENT products; the second is sold to an external asset owner before income accrues. + When Admin sets the business date to "01 December 2026" + And Admin creates a client with random data + 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 + 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_PYMNT_INTEREST_DAILY_EMI_360_30 | 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 zeroed across BOTH products and BOTH owners; one Retained Earning record per asset owner --- + 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 2 rows 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". + When Admin sets the business date to "01 December 2027" + And Admin creates a client with random data + 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 From 702454c310b612f301f34fbcadf1c14a0b9aa659 Mon Sep 17 00:00:00 2001 From: "mark.vituska" Date: Mon, 15 Jun 2026 11:35:35 +0200 Subject: [PATCH 2/3] FINERACT-1257: Implement Retained Earning Job --- .../AccountGLJournalEntryAnnualSummary.java | 63 +++++ ...GLJournalEntryAnnualSummaryRepository.java | 31 +++ .../api/GlobalConfigurationConstants.java | 6 + .../domain/ConfigurationDomainService.java | 12 + .../core/config/FineractProperties.java | 1 + .../infrastructure/jobs/service/JobName.java | 1 + .../LoanYearEndRetainedEarning.feature | 6 +- .../domain/LoanProductRepository.java | 4 + .../domain/ConfigurationDomainServiceJpa.java | 41 +++ .../RetainedEarningConfigurationService.java | 67 +++++ .../RetainedEarningJobConfiguration.java | 76 ++++++ .../RetainedEarningJobConstant.java | 56 ++++ .../RetainedEarningJobReader.java | 88 +++++++ .../RetainedEarningJobWriter.java | 93 +++++++ ...ccountGLJournalEntryAnnualSummaryData.java | 51 ++++ .../retainedearning/helper/DataParser.java | 74 ++++++ .../listener/RetainedEarningJobListener.java | 89 +++++++ ...ountGLJournalEntryAnnualSummaryRecord.java | 36 +++ .../services/RetainedEarningDataService.java | 34 +++ .../RetainedEarningDataServiceImpl.java | 242 +++++++++++++++++ .../src/main/resources/application.properties | 1 + .../db/changelog/tenant/changelog-tenant.xml | 2 + .../parts/0237_add_retained_earning_job.xml | 50 ++++ ..._retained_earning_global_configuration.xml | 85 ++++++ ...tainedEarningConfigurationServiceTest.java | 92 +++++++ .../RetainedEarningJobConfigurationTest.java | 108 ++++++++ .../RetainedEarningJobReaderTest.java | 146 +++++++++++ .../RetainedEarningJobWriterTest.java | 179 +++++++++++++ .../RetainedEarningScenarioTest.java | 245 ++++++++++++++++++ .../helper/DataParserTest.java | 201 ++++++++++++++ .../RetainedEarningJobListenerTest.java | 86 ++++++ .../RetainedEarningDataServiceImplTest.java | 222 ++++++++++++++++ .../common/GlobalConfigurationHelper.java | 45 ++++ 33 files changed, 2530 insertions(+), 3 deletions(-) create mode 100644 fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummary.java create mode 100644 fineract-accounting/src/main/java/org/apache/fineract/accounting/retainedearning/domain/AccountGLJournalEntryAnnualSummaryRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfiguration.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConstant.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReader.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriter.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/data/AccountGLJournalEntryAnnualSummaryData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParser.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListener.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/model/AccountGLJournalEntryAnnualSummaryRecord.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0237_add_retained_earning_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0238_add_retained_earning_global_configuration.xml create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningConfigurationServiceTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobConfigurationTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobReaderTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningJobWriterTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/RetainedEarningScenarioTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/helper/DataParserTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/listener/RetainedEarningJobListenerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/retainedearning/services/RetainedEarningDataServiceImplTest.java 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 fddea3f510f..3d0d16bf766 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 @@ -424,6 +424,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-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature index c78b2d0c3a1..3d34998d5ee 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature @@ -36,12 +36,12 @@ Feature: Loan Year End Retained Earning # --- 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" + #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" + #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" @@ -81,7 +81,7 @@ Feature: Loan Year End Retained Earning # --- Income zeroed across BOTH products and BOTH owners; one Retained Earning record per asset owner --- 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 2 rows for GL code "320000" with year end date "31 December 2026" + #And The journal entry annual summary table contains 2 rows 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 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 afb6aa28659..44497de4829 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 @@ -596,4 +596,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 c43cede1517..43a98a48934 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -85,6 +85,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 339750468cd..aa30649d2e1 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 @@ -255,4 +255,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0237_add_retained_earning_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0237_add_retained_earning_job.xml new file mode 100644 index 00000000000..11e4283068f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0237_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/0238_add_retained_earning_global_configuration.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0238_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/0238_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; } From 41b7131912c1813db4f3509204248d2d56890cbc Mon Sep 17 00:00:00 2001 From: "mark.vituska" Date: Mon, 15 Jun 2026 11:35:35 +0200 Subject: [PATCH 3/3] FINERACT-1257: Implement Retained Earning Job --- .../stepdef/common/AnnualSummaryStepDef.java | 74 +++++++++++++++++++ .../stepdef/reporting/ReportingStepDef.java | 13 +--- .../LoanYearEndRetainedEarning.feature | 41 ++++++---- 3 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/AnnualSummaryStepDef.java 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/reporting/ReportingStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/reporting/ReportingStepDef.java index a2219a62fdd..b442f41050e 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 @@ -44,7 +44,6 @@ 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 static final String HEAD_OFFICE_ID = "1"; private final FineractFeignClient fineractClient; @Then("Transaction Summary Report for date {string} has the following data:") @@ -69,7 +68,7 @@ public void transactionSummaryReportWithAssetOwnerColumnEmpty(final String dateS @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(runTrialBalanceForHeadOffice(dateStr), glCode, "endingbalance"); + 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) @@ -78,7 +77,7 @@ public void trialBalanceHasNonZeroEndingBalanceForGlAccount(final String dateStr @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(runTrialBalanceForHeadOffice(dateStr), glCode, "endingbalance"); + 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", @@ -156,14 +155,6 @@ private RunReportsResponse executeReport(final String reportName, final String d return response; } - private RunReportsResponse runTrialBalanceForHeadOffice(final String dateStr) { - final String date = LocalDate.parse(dateStr, FORMATTER).toString(); - final RunReportsResponse response = fineractClient.runReports().runReportGetData(TRIAL_BALANCE_REPORT, - Map.of("R_endDate", date, "R_officeId", HEAD_OFFICE_ID, "locale", "en", "dateFormat", "yyyy-MM-dd")); - assertThat(response.getData()).as("Report '%s' returned no data", TRIAL_BALANCE_REPORT).isNotNull(); - 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); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature index 3d34998d5ee..d55c7015a8f 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanYearEndRetainedEarning.feature @@ -2,18 +2,20 @@ Feature: Loan Year End Retained Earning Background: - # Calendar fiscal year + income/expense band + close-out target. - When Global config "last-day-of-financial-year" value set to "31" - And Global config "last-month-of-financial-year" value set to "12" - And Global config "income-expense-gl-accounts" value set to "400000-899999" + 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 client with random data + 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 | @@ -36,12 +38,12 @@ Feature: Loan Year End Retained Earning # --- 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" + 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" + 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" @@ -49,19 +51,22 @@ Feature: Loan Year End Retained Earning 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 handles multiple loan products and multiple asset owners - # Two loans on DIFFERENT products; the second is sold to an external asset owner before income accrues. + 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 client with random data + 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 + 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_PYMNT_INTEREST_DAILY_EMI_360_30 | 01 December 2026 | 1000 | 12 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + | 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 --- @@ -78,16 +83,20 @@ Feature: Loan Year End Retained Earning # --- Close FY2026 --- When Admin sets the business date to "02 January 2027" And Admin runs the Retained Earning Job - # --- Income zeroed across BOTH products and BOTH owners; one Retained Earning record per asset owner --- + # --- 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 2 rows for GL code "320000" with year end date "31 December 2026" + 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 client with random data + 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 |