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 d62a646e1..41cd2bf0b 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 @@ -32,6 +32,7 @@ public final class GlobalConfigurationConstants { public static final String FORCE_PASSWORD_RESET_DAYS = "force-password-reset-days"; public static final String GRACE_ON_PENALTY_POSTING = "grace-on-penalty-posting"; public static final String SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END = "savings-interest-posting-current-period-end"; + public static final String FD_INTEREST_POSTING_APPROVAL = "fd-interest-posting-approval"; public static final String FINANCIAL_YEAR_BEGINNING_MONTH = "financial-year-beginning-month"; public static final String MIN_CLIENTS_IN_GROUP = "min-clients-in-group"; public static final String MAX_CLIENTS_IN_GROUP = "max-clients-in-group"; 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 2529cee37..fb0c03c76 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 @@ -69,6 +69,12 @@ public interface ConfigurationDomainService { boolean isSavingsInterestPostingAtCurrentPeriodEnd(); + /** + * Whether fixed deposit interest is reported and approved before it is posted, instead of being + * posted directly by the nightly job. + */ + boolean isFixedDepositInterestPostingApprovalEnabled(); + Integer retrieveFinancialYearBeginningMonth(); Integer retrieveMinAllowedClientsInGroup(); 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 b841d9c06..a0027c154 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 @@ -44,6 +44,7 @@ public enum JobName { APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), // SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), // + FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), // diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/FixedDepositType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/FixedDepositType.java new file mode 100644 index 000000000..cff5fae02 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/FixedDepositType.java @@ -0,0 +1,100 @@ +/** + * 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.portfolio.savings; + +import java.util.Locale; + +/** + * The deposit scheme a fixed deposit product is sold under, held as {@code m_savings_product.fd_type}. + * + *

+ * The three schemes differ in where a posting of interest ends up, which is what the interest posting + * report has to show and the posting itself has to honour: + * + *

+ * + *

+ * The database column is a string rather than an enum ordinal, so the names here are the stored + * values and must not be renamed. {@link #fromString(String)} is lenient about case and blanks + * because the column carries a default of SINGLE and older products predate the column entirely. + */ +public enum FixedDepositType { + + /** Interest is held in the deposit and paid at maturity. The default for a product that names none. */ + SINGLE, + /** Interest is compounded into the deposit; the maturity value includes it. */ + DOUBLE, + /** Interest is posted monthly and transferred to the linked savings account. */ + SPECIAL; + + /** + * The scheme named by a stored {@code fd_type}, defaulting to {@link #SINGLE}. + * + *

+ * A null, blank or unrecognised value reads as SINGLE: that is the column's own default, and a + * product carrying something else is a misconfiguration that must not stop its customers' interest + * from being reported. + */ + public static FixedDepositType fromString(final String fdType) { + if (fdType == null || fdType.isBlank()) { + return SINGLE; + } + for (final FixedDepositType candidate : values()) { + if (candidate.name().equalsIgnoreCase(fdType.trim())) { + return candidate; + } + } + return SINGLE; + } + + public static boolean isValid(final String fdType) { + if (fdType == null || fdType.isBlank()) { + return false; + } + for (final FixedDepositType candidate : values()) { + if (candidate.name().equalsIgnoreCase(fdType.trim())) { + return true; + } + } + return false; + } + + /** Whether interest leaves the deposit for the linked savings account each time it is posted. */ + public boolean isPaidOutMonthly() { + return this == SPECIAL; + } + + /** Whether a posting of interest adds to the deposit's own balance. */ + public boolean isRetainedInDeposit() { + return this != SPECIAL; + } + + /** What a customer sees: "FD Special", "FD Double", "FD Single". */ + public String displayName() { + return "FD " + name().charAt(0) + name().substring(1).toLowerCase(Locale.ENGLISH); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java index d31549dbb..b8b1f5799 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java @@ -84,4 +84,8 @@ public final class LedgerTxnPrefix { // ── Savings ─────────────────────────────────────────────────────────────── /** Pooled savings interest posting for one product and date. */ public static final String SAVINGS_INTEREST_POOLED = "SAVINT"; + + // ── Fixed deposits ──────────────────────────────────────────────────────── + /** Approved fixed deposit interest posting report, for one product, branch and date. */ + public static final String FD_INTEREST_POSTED = "FDINT"; } 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 c393095d9..2b5f99d3b 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 @@ -191,6 +191,16 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService return property.isEnabled(); } + /** + * Read on every run of the interest posting job and of the day close, so the two paths can never + * both pay the same deposit: with this on the job leaves fixed deposits alone and they are paid + * from an approved report, with it off the job pays them directly as it always did. + */ + @Override + public boolean isFixedDepositInterestPostingApprovalEnabled() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_INTEREST_POSTING_APPROVAL).isEnabled(); + } + @Override public Integer retrieveFinancialYearBeginningMonth() { final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java index 9f4e81033..02b3c950d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java @@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean { private final ExtendedSpringLiquibaseFactory liquibaseFactory; private final TenantDataSourceFactory tenantDataSourceFactory; private final Environment environment; + private final TenantInitialUserProvisioner initialUserProvisioner; // DO NOT REMOVE! Required for liquibase custom task initialization private final List customTaskChangesForDependencyInjection; @@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean { // configuration // settings, and we should not use them to control the script order if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) { + // a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones + final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource); ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier()); applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(), (ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds)); + if (brandNewTenant) { + initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier()); + } } SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT, tenant.getTenantIdentifier()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java new file mode 100644 index 000000000..80ca8c636 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java @@ -0,0 +1,90 @@ +/** + * 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.core.service.migration; + +import java.util.Locale; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.useradministration.service.AppUserConstants; +import org.springframework.core.env.Environment; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3, + * which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the + * public default password). + * + * Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never + * touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and + * break validation on every tenant that already ran it. + * + * Usernames become {@code _admin} and {@code _interop}. The system user keeps the + * {@link AppUserConstants#SYSTEM_USER_NAME} username because scheduler jobs, COB and other background processes look it + * up by that name; only its password is changed. All three share one password: the value of + * {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set, + * otherwise {@link #DEFAULT_PASSWORD}. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class TenantInitialUserProvisioner { + + static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password"; + + private static final long ADMIN_ID = 1L; + private static final long SYSTEM_ID = 2L; + private static final long INTEROP_ID = 3L; + // Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars). + static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure"; + + private final Environment environment; + + public void provision(final DataSource dataSource, final String tenantIdentifier) { + final JdbcTemplate jdbc = new JdbcTemplate(dataSource); + final String prefix = usernamePrefix(tenantIdentifier); + + final String configured = environment.getProperty(PASSWORD_PROPERTY); + final boolean overridden = configured != null && !configured.isBlank(); + final String password = overridden ? configured : DEFAULT_PASSWORD; + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + final String encoded = encoder.encode(password); + + update(jdbc, ADMIN_ID, prefix + "_admin", encoded); + update(jdbc, SYSTEM_ID, AppUserConstants.SYSTEM_USER_NAME, encoded); + update(jdbc, INTEROP_ID, prefix + "_interop", encoded); + + log.warn("Tenant '{}' initial users created: {}_admin, {}, {}_interop (password: {}). Change it after first login.", + tenantIdentifier, prefix, AppUserConstants.SYSTEM_USER_NAME, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default"); + } + + private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) { + jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username, + encodedPassword, id); + } + + static String usernamePrefix(final String tenantIdentifier) { + final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", ""); + return cleaned.isEmpty() ? "tenant" : cleaned; + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 4868e14fb..c93b3918e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -48,6 +48,8 @@ import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator; +import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -91,6 +93,8 @@ public class BranchDayRegisterApiResource { private final VaultPositionService vaultPositionService; private final SavingsInterestPostingReportService interestPostingReportService; private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; + private final FixedDepositInterestPostingReportService fdInterestPostingReportService; + private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator; private final Gson gson = new Gson(); // ── GET /v1/branchday ── the register itself ────────────────────────────── @@ -247,6 +251,16 @@ public class BranchDayRegisterApiResource { result.put("denominationIn", countedIn); result.put("denominationOut", countedOut); + // What will stop this close, so the screen can say so before the button is pressed rather than + // after. The same lists closeDay() refuses on, read-only here. + result.put("savingsInterestPending", interestPostingReportService.reportsBlockingDayClose(officeId, businessDate)); + // One call for the deposits: what is blocking, and the products and amounts behind it, so the + // branch sees what it is being asked to approve without leaving the close screen. Pricing a + // branch's deposits is the expensive part and is done once. + final Map fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate); + result.put("fdInterestPending", fdInterest.get("pending")); + result.put("fdInterestDue", fdInterest.get("due")); + return gson.toJson(result); } @@ -625,6 +639,17 @@ public class BranchDayRegisterApiResource { + ". Approve it under Reports → Interest Posting Reports, then close the day."); } + // ── And its fixed deposit interest reports, on the same grounds ── + // Only while deposits are on the approval path at all; with that switched off the posting job + // pays them directly and there is nothing here to approve. + final List fdInterestPending = fdInterestPostingReportService.reportsBlockingDayClose(officeId, businessDate); + if (!fdInterestPending.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.report.pending", + "Cannot close the day — this branch's fixed deposit interest posting report is not approved for: " + + String.join(", ", fdInterestPending) + + ". Approve it under Reports → FD Interest Posting Reports, then close the day."); + } + // ── The head office closes last ── // Every branch that opened the day closes first and posts its own interest; a branch that // never opened is not waited for. @@ -760,6 +785,23 @@ public class BranchDayRegisterApiResource { } } + // ── And the branch's approved fixed deposit interest, the same way ── + // Separate from the savings run so a deposit product with a GL problem cannot stop savings + // interest that has already posted cleanly, and so the refusal names which of the two failed. + final List fdToPost = new ArrayList<>(fdInterestPostingReportService.approvedReportIds(officeId, businessDate)); + if (isHeadOffice) { + fdToPost.addAll(fdInterestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId)); + } + if (!fdToPost.isEmpty()) { + log.info("Day close {}: office {} posting {} approved FD interest report(s)", businessDate, officeId, fdToPost.size()); + final List fdFailures = fdInterestPostingOrchestrator.postReports(fdToPost); + if (!fdFailures.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.posting.failed", + "Cannot close the day — fixed deposit interest could not be posted: " + String.join("; ", fdFailures) + + ". The report is still approved; fix the cause and close the day again."); + } + } + // "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and // this write are two separate statements, so two managers pressing Close at once (or one // double-click) both got past the check. The second UPDATE then overwrote the first's diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 2082c2c46..8cac4b0b7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -424,8 +424,8 @@ public class SavingsPooledInterestPostingService { * columns are NOT NULL, so entries are stamped to the system account the scheduler uses. */ private Long systemUserId() { - final List ids = jdbcTemplate.queryForList( - "SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class); + // id 2 is the seeded system user; its username is per-tenant now, so it is found by id + final List ids = jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class); return ids.isEmpty() ? 1L : ids.get(0); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingOrchestrator.java new file mode 100644 index 000000000..fb6e8bee3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingOrchestrator.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.portfolio.savings.fdinterest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Posts approved fixed deposit interest reports, one transaction each. + * + *

+ * Called as a branch closes its day. A report that cannot be posted does not take the others down + * with it: its failure is written to the report, which stays APPROVED, and reported back to the + * caller so the close can be refused with a reason the branch can act on. Closing again once the + * cause is fixed posts it. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class FixedDepositInterestPostingOrchestrator { + + private final JdbcTemplate jdbcTemplate; + private final FixedDepositInterestPostingService postingService; + private final FixedDepositInterestPostingReportService reportService; + private final PlatformTransactionManager transactionManager; + + /** + * Posts each report in a transaction of its own. + * + * @return one message per report that could not be posted, empty where all of them were + */ + public List postReports(final List reportIds) { + final TransactionTemplate newTransaction = new TransactionTemplate(this.transactionManager); + newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + final List failures = new ArrayList<>(); + for (final Long reportId : reportIds) { + final Map report = this.jdbcTemplate + .queryForMap("SELECT fd_product_id, posting_date FROM m_fd_interest_posting_report WHERE id = ?", reportId); + final long productId = ((Number) report.get("fd_product_id")).longValue(); + final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + try { + final BigDecimal paid = newTransaction.execute(status -> this.postingService.postReport(reportId)); + log.info("FD interest: report {} for product {} posted {} for {}", reportId, productId, paid, postingDate); + } catch (final RuntimeException e) { + log.error("FD interest: report {} for product {} failed to post for {}", reportId, productId, postingDate, e); + newTransaction.executeWithoutResult(status -> { + this.reportService.recordPostingFailure(reportId, e.getMessage()); + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_run + SET status = 'FAILED', failure_reason = ? + WHERE report_id = ? + """, truncate(e.getMessage()), reportId); + }); + failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage()); + } + } + return failures; + } + + private static String truncate(final String reason) { + if (reason == null) { + return "unknown failure"; + } + return reason.length() > 1000 ? reason.substring(0, 1000) : reason; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java new file mode 100644 index 000000000..486c5df0f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java @@ -0,0 +1,1011 @@ +/** + * 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.portfolio.savings.fdinterest; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.FixedDepositType; +import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * The fixed deposit interest posting report: what each deposit of one branch will be paid on its + * product's posting date, put in front of that branch before any money moves. + * + *

+ * One report per branch and product. On a posting date the noon job generates one for every branch + * holding deposits with interest due; a user of that branch may regenerate it at any time — the + * previous report is superseded, not overwritten — and a second user of that branch approves it. The + * branch's approved reports are posted when it closes its day, and until they are approved it cannot + * close. The head office closes last, once every branch open that day has closed. + * + *

+ * Where the interest ends up is the product's scheme, and the report says so deposit by deposit: + * a {@link FixedDepositType#SPECIAL} deposit's interest leaves for the customer's linked savings + * account, while {@link FixedDepositType#SINGLE} and {@link FixedDepositType#DOUBLE} keep it in the + * deposit until maturity. A checker approving a special product's report is approving payments out + * of the bank, which is why the destination account is on every line. + * + *

+ * Unlike savings, a deposit has no daily interest ledger to read a figure from: the amount is worked + * out by the same interest engine the posting job uses, run here without the step that writes it + * down. That makes generation the expensive part and it is done once per run, not once per screen. + */ +@Service +@Slf4j +public class FixedDepositInterestPostingReportService { + + public static final String GENERATED = "GENERATED"; + public static final String APPROVED = "APPROVED"; + public static final String POSTED = "POSTED"; + public static final String SUPERSEDED = "SUPERSEDED"; + + private static final int MONEY_SCALE = 2; + private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + + /** + * The product's posting cycle in words, read off {@code SavingsPostingInterestPeriodType}'s own + * values (1 daily, 4 monthly, 5 quarterly, 6 biannual, 7 annual) so a checker reading a period range + * also reads what kind of period it is, not just its two dates. + */ + private static final String POSTING_PERIOD_LABEL_CASE = """ + CASE p.interest_posting_period_enum + WHEN 1 THEN 'Daily' WHEN 4 THEN 'Monthly' WHEN 5 THEN 'Quarterly' + WHEN 6 THEN 'Half-yearly' WHEN 7 THEN 'Yearly' ELSE 'Interest' + END + """; + + private final JdbcTemplate jdbcTemplate; + private final SavingsAccountReadPlatformService savingsAccountReadPlatformService; + private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + private final FixedDepositInterestPostingService postingService; + /** + * Borrowed for the branch rules — which branch may act for which, and whether a branch is working + * today. They are the same rules as for savings and must stay the same: a bank where a branch + * could approve its deposits but not its savings, or the other way round, would be answering the + * question "who closes the day" twice with two different answers. + */ + private final SavingsInterestPostingReportService branchAuthority; + private final TransactionTemplate transactionTemplate; + + public FixedDepositInterestPostingReportService(final JdbcTemplate jdbcTemplate, + final SavingsAccountReadPlatformService savingsAccountReadPlatformService, + final SavingsAccountWritePlatformService savingsAccountWritePlatformService, + final ConfigurationDomainService configurationDomainService, final FixedDepositInterestPostingService postingService, + final SavingsInterestPostingReportService branchAuthority, final PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.savingsAccountReadPlatformService = savingsAccountReadPlatformService; + this.savingsAccountWritePlatformService = savingsAccountWritePlatformService; + this.configurationDomainService = configurationDomainService; + this.postingService = postingService; + this.branchAuthority = branchAuthority; + // A transaction of its own, as for savings: the noon job runs inside the batch step's + // transaction, and a report committed separately is on screen at once and holds no row locks + // for the rest of the run. + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + /** + * One posting due on one deposit, before it is written down — a deposit with two unpaid periods + * has two of these, one per period, because that is how the engine will look for them again. + */ + public record DueLine(Long accountId, String accountNo, String clientName, Long officeId, Long productId, String fdType, + LocalDate postingDate, BigDecimal interest, BigDecimal balanceBefore, BigDecimal depositAmount, BigDecimal interestRate, + LocalDate maturityDate, Long payoutSavingsAccountId, String payoutAccountNo, LocalDate periodFrom) {} + + /** One unpaid posting the engine worked out: the date it belongs to and what it comes to. */ + private record ComputedPosting(Long accountId, LocalDate postingDate, BigDecimal interest) {} + + /** A product with interest due in one branch. */ + private record DueKey(Long productId, Long officeId) {} + + // ── computing what is due ──────────────────────────────────────────────────────────────────── + + /** + * What every candidate fixed deposit has earned and not yet been paid, as at the posting date. + * + *

+ * The interest engine is run exactly as the posting job runs it, and then stopped: it returns the + * deposit with its new interest transactions attached in memory, and only the job's batch write + * turns those into rows. Nothing here writes one, so this can be called as often as a screen asks. + * + *

+ * A deposit the engine finds nothing for — its posting period has not ended yet — contributes no + * line. That is also what makes "due" meaningful at all for a deposit: the cheap database test + * that selects candidates cannot tell a period end from a day in the middle of one. + * + * @param officeId + * one branch, or null for every branch + * @param productId + * one product, or null for every product + */ + public List computeDueInterest(final LocalDate postingDate, final Long officeId, final Long productId) { + final boolean backdatedTxnsAllowedTill = this.configurationDomainService.retrievePivotDateConfig(); + final List candidates = this.savingsAccountReadPlatformService + .retrieveFixedDepositsDueForInterestPosting(backdatedTxnsAllowedTill, postingDate, officeId, productId); + if (candidates.isEmpty()) { + return List.of(); + } + + final List postings = new ArrayList<>(); + for (final SavingsAccountData deposit : candidates) { + postings.addAll(computePostingsFor(deposit, backdatedTxnsAllowedTill)); + } + if (postings.isEmpty()) { + return List.of(); + } + return describe(postings); + } + + /** + * One deposit's unpaid postings, each with the date the engine gave it. + * + *

+ * The date is kept, never flattened away. The engine decides a period has already been paid by + * looking for an interest transaction on exactly that date, so a posting recorded under any other + * date would be invisible to it and the period would be computed, reported and paid a second time. + * For the same reason two unpaid periods stay two postings rather than being added together. + * + *

+ * A deposit the engine cannot price — a chart it cannot read, a rate it cannot resolve — is left + * out of the report rather than stopping the branch's other customers from being reported. Missing + * from the report is visible; a wrong figure quietly paid is not. + */ + private List computePostingsFor(final SavingsAccountData deposit, final boolean backdatedTxnsAllowedTill) { + final SavingsAccountData computed; + try { + computed = this.savingsAccountWritePlatformService.postInterest(deposit, false, null, backdatedTxnsAllowedTill); + } catch (final RuntimeException e) { + log.error("FD interest report: deposit {} could not be priced and is left out of the report", deposit.getId(), e); + return List.of(); + } + if (computed == null || computed.getSavingsAccountTransactionData() == null) { + return List.of(); + } + final List postings = new ArrayList<>(); + for (final SavingsAccountTransactionData transaction : computed.getSavingsAccountTransactionData()) { + // Only what this run has just worked out: a transaction with an id is already in the + // account and has been paid. + if (transaction.getId() != null || transaction.getTransactionType() == null + || transaction.getTransactionType().getId() == null) { + continue; + } + final SavingsAccountTransactionType type = SavingsAccountTransactionType + .fromInt(transaction.getTransactionType().getId().intValue()); + if (!type.isInterestPosting() || transaction.getAmount() == null + || transaction.getAmount().compareTo(BigDecimal.ZERO) <= 0 || transaction.getTransactionDate() == null) { + continue; + } + postings.add(new ComputedPosting(deposit.getId(), transaction.getTransactionDate(), transaction.getAmount())); + } + return postings; + } + + /** + * The deposits behind the computed figures, with what a checker needs to judge them: the principal, + * the rate, the maturity date, and for a special deposit the savings account its interest leaves + * for. + */ + private List describe(final List postings) { + final String ids = postings.stream().map(ComputedPosting::accountId).distinct().map(String::valueOf) + .collect(Collectors.joining(",")); + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS account_id, sa.account_no, sa.product_id, sa.activatedon_date, + COALESCE(c.display_name, g.display_name) AS client_name, + COALESCE(c.office_id, g.office_id) AS office_id, + COALESCE(sa.account_balance_derived, 0) AS balance, + sa.nominal_annual_interest_rate AS interest_rate, + sp.fd_type, datp.deposit_amount, datp.maturity_date, + datp.transfer_interest_to_linked_account, datp.transfer_to_savings_account_id, + ls.account_no AS payout_account_no + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + LEFT JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + LEFT JOIN m_savings_account ls ON ls.id = datp.transfer_to_savings_account_id + WHERE sa.id IN (%s) + ORDER BY sa.account_no + """.formatted(ids)); + + final Map> byAccount = new LinkedHashMap<>(); + for (final Map row : rows) { + byAccount.put(((Number) row.get("account_id")).longValue(), row); + } + + // Where each deposit's last paid period ended, so the new one's own period can be shown + // starting the day after — not computed from the posting itself, which only carries the day + // the period ends. A deposit with no interest posted yet starts counting from its activation. + final Map lastPaidThrough = previousPeriodEnds(byAccount.keySet(), postings); + + // Earliest date first within each deposit, so a deposit owed two periods on this report has + // its second period's "from" follow the first's own "to" rather than both reading the same + // opening date. + final List ordered = postings.stream() + .sorted(Comparator.comparing(ComputedPosting::accountId).thenComparing(ComputedPosting::postingDate)).toList(); + + final List lines = new ArrayList<>(); + for (final ComputedPosting posting : ordered) { + final Map row = byAccount.get(posting.accountId()); + if (row == null) { + continue; + } + final Object officeId = row.get("office_id"); + if (officeId == null) { + // A deposit whose customer has no branch cannot be put on any branch's report. + log.warn("FD interest report: deposit {} has no branch and is left out", posting.accountId()); + continue; + } + final FixedDepositType fdType = FixedDepositType.fromString((String) row.get("fd_type")); + // The destination is only meaningful where the scheme actually pays out and the deposit + // names an account to pay into. + final boolean paysOut = fdType.isPaidOutMonthly() && Boolean.TRUE.equals(row.get("transfer_interest_to_linked_account")); + final Object payoutId = paysOut ? row.get("transfer_to_savings_account_id") : null; + final LocalDate periodStart = lastPaidThrough.get(posting.accountId()); + final LocalDate periodFrom = periodStart == null ? toLocalDate(row.get("activatedon_date")) : periodStart.plusDays(1); + lastPaidThrough.put(posting.accountId(), posting.postingDate()); + lines.add(new DueLine(posting.accountId(), (String) row.get("account_no"), (String) row.get("client_name"), + ((Number) officeId).longValue(), ((Number) row.get("product_id")).longValue(), fdType.name(), + posting.postingDate(), posting.interest(), (BigDecimal) row.get("balance"), + (BigDecimal) row.get("deposit_amount"), (BigDecimal) row.get("interest_rate"), + toLocalDate(row.get("maturity_date")), payoutId == null ? null : ((Number) payoutId).longValue(), + paysOut ? (String) row.get("payout_account_no") : null, periodFrom)); + } + return lines; + } + + /** + * Each deposit's most recent posted interest, as at the earliest of its postings being reported + * here — null for a deposit with none, which starts its first period from activation instead. + */ + private Map previousPeriodEnds(final Set accountIds, final List postings) { + if (accountIds.isEmpty()) { + return new LinkedHashMap<>(); + } + final Map earliestNew = new LinkedHashMap<>(); + for (final ComputedPosting posting : postings) { + earliestNew.merge(posting.accountId(), posting.postingDate(), (a, b) -> a.isBefore(b) ? a : b); + } + final String ids = accountIds.stream().map(String::valueOf).collect(Collectors.joining(",")); + final Map result = new LinkedHashMap<>(); + for (final Map row : this.jdbcTemplate.queryForList(""" + SELECT savings_account_id, MAX(transaction_date) AS last_date + FROM m_savings_account_transaction + WHERE savings_account_id IN (%s) AND transaction_type_enum = 3 AND is_reversed = false + GROUP BY savings_account_id + """.formatted(ids))) { + final Long accountId = ((Number) row.get("savings_account_id")).longValue(); + final LocalDate lastDate = toLocalDate(row.get("last_date")); + final LocalDate newEarliest = earliestNew.get(accountId); + if (lastDate != null && (newEarliest == null || lastDate.isBefore(newEarliest))) { + result.put(accountId, lastDate); + } + } + return result; + } + + private static LocalDate toLocalDate(final Object value) { + return value instanceof java.sql.Date date ? date.toLocalDate() : null; + } + + // ── generation ─────────────────────────────────────────────────────────────────────────────── + + /** + * The noon job: generates a report for every branch and product with deposit interest due that + * does not already have one. + * + *

+ * The interest is worked out once for the whole bank and then split by branch and product, since + * pricing a deposit is the expensive part and doing it per report would price the same portfolio + * many times over. Each report is still written in its own transaction, so one that cannot be — a + * GL mapping missing, say — does not stop the others; the failures are raised together at the end + * so the job is recorded as failed. + * + * @return the ids of the reports generated + */ + public List generateDue(final LocalDate postingDate) { + final List due = computeDueInterest(postingDate, null, null); + final List generated = new ArrayList<>(); + final List failures = new ArrayList<>(); + for (final Map.Entry> group : groupByProductAndOffice(due).entrySet()) { + final DueKey key = group.getKey(); + if (activeReportId(key.productId(), key.officeId(), postingDate) != null) { + continue; + } + try { + generated.add(transactionTemplate + .execute(status -> persistReport(key.productId(), key.officeId(), postingDate, "AUTO", null, group.getValue()))); + } catch (final RuntimeException e) { + log.error("FD interest report: product {} branch {} could not be reported for {}", key.productId(), key.officeId(), + postingDate, e); + failures.add("product " + key.productId() + " branch " + key.officeId() + ": " + e.getMessage()); + } + } + if (!failures.isEmpty()) { + throw new IllegalStateException("Fixed deposit interest posting report could not be generated on " + postingDate + " for " + + failures.size() + " report(s): " + String.join("; ", failures)); + } + return generated; + } + + /** + * Generates the report afresh for one branch and product, on the user's request. + * + *

+ * Not for a date after the business date, whose interest has not been earned yet. A posted report + * cannot be regenerated: its money has moved. An approved one can, and has to be approved again. + */ + public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.future.date", + "The report for " + postingDate + " cannot be generated before that date."); + } + authorize(user, officeId, businessDate); + final List due = computeDueInterest(postingDate, officeId, productId); + return transactionTemplate.execute(status -> persistReport(productId, officeId, postingDate, "MANUAL", user.getId(), due)); + } + + /** + * Approves a generated report, by someone other than the user who generated it. + * + *

+ * The two-user rule is the point of the report. Interest posting moves money to customers — and + * out of the bank entirely, for a special deposit paying into a linked savings account — so the + * figures are put up by one person and signed off by another. A branch that cannot find a second + * user has a staffing problem, not a software one; the alternative is a control that any single + * user can walk straight through. + * + *

+ * A user of the report's branch approves it; the head office may approve it only for a branch that + * did not open that day, and the report records that it did. + */ + public void approve(final Long reportId, final AppUser user) { + final List> found = this.jdbcTemplate + .queryForList("SELECT office_id, generated_by, status FROM m_fd_interest_posting_report WHERE id = ?", reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.not.found", + "Fixed deposit interest posting report " + reportId + " does not exist."); + } + final Map report = found.get(0); + final Long officeId = ((Number) report.get("office_id")).longValue(); + final Object generatedBy = report.get("generated_by"); + if (generatedBy != null && ((Number) generatedBy).longValue() == user.getId()) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.same.maker.checker", + "You generated this report, so you cannot approve it. Another user of this branch has to approve the interest " + + "before it can be posted. Regenerating it yourself will not change that."); + } + final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate()); + final int approved = this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_report + SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ? + WHERE id = ? AND status = 'GENERATED' + """, user.getId(), onBehalf, reportId); + if (approved == 0) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.not.generated", + "Only a report in GENERATED status can be approved. Reload the report — it may have been approved, " + + "regenerated or posted already."); + } + } + + /** + * Writes one report and its lines, superseding whatever stood for that branch, product and date. + * + *

+ * Runs inside the caller's transaction. Each deposit's interest is rounded to two decimals and the + * paise the rounding loses is given to the largest deposit, where it is proportionally smallest, + * so the report's total is exactly what the branch will pay out. + */ + private Long persistReport(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, + final Long userId, final List lines) { + final List> previous = this.jdbcTemplate.queryForList(""" + SELECT id, status FROM m_fd_interest_posting_report + WHERE fd_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + FOR UPDATE + """, productId, officeId, postingDate); + for (final Map report : previous) { + if (POSTED.equals(report.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.already.posted", + "Fixed deposit interest for this product and branch on " + postingDate + " has already been posted; the report" + + " cannot be regenerated."); + } + } + if (lines.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.nothing.due", + "No fixed deposit interest is due for this product in this branch on " + postingDate + "."); + } + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_report SET status = 'SUPERSEDED' + WHERE fd_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED') + """, productId, officeId, postingDate); + + // Refused here, before anybody approves figures that could never be posted. + final FixedDepositInterestPostingService.PostingGlAccounts gls = this.postingService.resolvePostingGlAccounts(productId); + + // Oldest period first, so a deposit owed two postings is written down in the order the money + // would actually have reached it, and each line's opening balance follows the one before. + final List ordered = lines.stream() + .sorted(Comparator.comparing(DueLine::postingDate).thenComparing(DueLine::accountNo, + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + // Rounded per line, not per deposit: one deposit can hold more than one line here. + final List rounded = new ArrayList<>(ordered.size()); + BigDecimal grossTotal = BigDecimal.ZERO; + BigDecimal roundedTotal = BigDecimal.ZERO; + for (final DueLine line : ordered) { + final BigDecimal value = line.interest().setScale(MONEY_SCALE, MONEY_ROUNDING); + rounded.add(value); + grossTotal = grossTotal.add(line.interest()); + roundedTotal = roundedTotal.add(value); + } + final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal residual = pooled.subtract(roundedTotal); + if (residual.signum() != 0) { + int largest = 0; + for (int i = 1; i < ordered.size(); i++) { + final BigDecimal candidate = ordered.get(i).balanceBefore(); + final BigDecimal best = ordered.get(largest).balanceBefore(); + if ((candidate == null ? BigDecimal.ZERO : candidate).compareTo(best == null ? BigDecimal.ZERO : best) > 0) { + largest = i; + } + } + rounded.set(largest, rounded.get(largest).add(residual)); + } + + final String fdType = ordered.get(0).fdType(); + final Long reportId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_fd_interest_posting_report + (fd_product_id, fd_type, office_id, posting_date, status, total_interest, account_count, + payable_gl_account_id, paid_gl_account_id, generation_mode, generated_by, generated_at) + VALUES (?, ?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, productId, fdType, officeId, postingDate, pooled, ordered.size(), gls.payableGl(), gls.paidGl(), mode, + userId); + + // Where a deposit holds two postings, the second opens on the balance the first leaves behind. + final Map runningBalance = new LinkedHashMap<>(); + for (int i = 0; i < ordered.size(); i++) { + final DueLine line = ordered.get(i); + final BigDecimal interest = rounded.get(i); + final BigDecimal opening = line.balanceBefore() == null ? BigDecimal.ZERO + : line.balanceBefore().setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal before = runningBalance.getOrDefault(line.accountId(), opening); + // The deposit's balance the moment the interest is credited. A special deposit's interest + // does not stay there — the existing transfer-interest-to-savings job sweeps it to the + // linked account afterwards, which is a transaction of its own — so the destination is on + // the line rather than being netted off here, where it would hide a payment the checker is + // approving. + final BigDecimal after = before.add(interest); + runningBalance.put(line.accountId(), after); + this.jdbcTemplate.update(""" + INSERT INTO m_fd_interest_posting_report_line + (report_id, fd_account_id, account_no, client_name, office_id, interest_posting_date, interest_amount, + balance_before, balance_after, deposit_amount, interest_rate, maturity_date, payout_savings_account_id, + payout_account_no, interest_period_from) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, reportId, line.accountId(), line.accountNo(), line.clientName(), line.officeId(), line.postingDate(), interest, + before, after, line.depositAmount(), line.interestRate(), line.maturityDate(), line.payoutSavingsAccountId(), + line.payoutAccountNo(), line.periodFrom()); + } + + log.info("FD interest report {}: product {} ({}) branch {} on {} ({}) — {} across {} posting(s)", reportId, productId, fdType, + officeId, postingDate, mode, pooled, ordered.size()); + return reportId; + } + + private Map> groupByProductAndOffice(final List lines) { + final Map> grouped = new LinkedHashMap<>(); + for (final DueLine line : lines) { + grouped.computeIfAbsent(new DueKey(line.productId(), line.officeId()), key -> new ArrayList<>()).add(line); + } + return grouped; + } + + // ── day close ──────────────────────────────────────────────────────────────────────────────── + + /** + * What stops one branch from closing the day: a product with deposit interest due in the branch + * and no approved (or posted) report for the date, and any older report of the branch still + * awaiting approval — one left from a day the branch did not open. A report that was never + * generated blocks the close too, which is why the due figures are worked out here rather than + * trusting the noon job to have run. + */ + public List reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) { + if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) { + return List.of(); + } + return blockingFrom(officeId, businessDate, groupByProductAndOffice(computeDueInterest(businessDate, officeId, null))); + } + + /** + * What the day close screen shows a branch about its deposits: what is blocking the close, and the + * products and amounts behind it. + * + *

+ * Both come out of one pricing run, because working out a branch's deposit interest is the + * expensive part and the screen asks for both at once. + * + * @return {@code pending} — the same list {@link #reportsBlockingDayClose} returns — and {@code due}, + * one row per product with its deposit count and total + */ + public Map dayCloseInterestView(final Long officeId, final LocalDate businessDate) { + final Map view = new LinkedHashMap<>(); + if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) { + view.put("pending", List.of()); + view.put("due", List.of()); + return view; + } + final Map> grouped = groupByProductAndOffice(computeDueInterest(businessDate, officeId, null)); + view.put("pending", blockingFrom(officeId, businessDate, grouped)); + + final List> due = new ArrayList<>(); + for (final Map.Entry> group : grouped.entrySet()) { + BigDecimal total = BigDecimal.ZERO; + for (final DueLine line : group.getValue()) { + total = total.add(line.interest()); + } + final Map row = new LinkedHashMap<>(); + row.put("productId", group.getKey().productId()); + row.put("productName", productName(group.getKey().productId())); + row.put("fdType", group.getValue().get(0).fdType()); + row.put("accountCount", group.getValue().size()); + row.put("totalInterest", total.setScale(MONEY_SCALE, MONEY_ROUNDING)); + row.put("reportStatus", reportStatus(group.getKey().productId(), officeId, businessDate)); + due.add(row); + } + view.put("due", due); + return view; + } + + /** Products due with no approved report, plus any of the branch's reports still awaiting approval. */ + private List blockingFrom(final Long officeId, final LocalDate businessDate, final Map> grouped) { + final Set blocking = new LinkedHashSet<>(); + for (final DueKey due : grouped.keySet()) { + final Long settled = this.jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_fd_interest_posting_report + WHERE fd_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED') + """, Long.class, due.productId(), officeId, businessDate); + if (settled == null || settled == 0) { + blocking.add(productName(due.productId()) + " (" + businessDate + ")"); + } + } + for (final Map pending : this.jdbcTemplate.queryForList(""" + SELECT p.name, r.posting_date FROM m_fd_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_product_id + WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ? + ORDER BY r.posting_date + """, officeId, businessDate)) { + blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")"); + } + return new ArrayList<>(blocking); + } + + /** Where this product's report for the branch and date stands, or NONE where there is not one. */ + private String reportStatus(final Long productId, final Long officeId, final LocalDate postingDate) { + final List status = this.jdbcTemplate.queryForList(""" + SELECT status FROM m_fd_interest_posting_report + WHERE fd_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + ORDER BY id DESC LIMIT 1 + """, String.class, productId, officeId, postingDate); + return status.isEmpty() ? "NONE" : status.get(0); + } + + /** The branch's approved reports, posted when it closes the day. */ + public List approvedReportIds(final Long officeId, final LocalDate upToDate) { + return this.jdbcTemplate.queryForList(""" + SELECT id FROM m_fd_interest_posting_report + WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ? + ORDER BY posting_date, fd_product_id + """, Long.class, officeId, upToDate); + } + + /** + * Approved reports of branches that did not open on the date — what the head office posts at its + * own close, the branches having no close of their own that day. + */ + public List approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) { + return this.jdbcTemplate.queryForList(""" + SELECT r.id FROM m_fd_interest_posting_report r + WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ? + AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id + AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?))) + ORDER BY r.posting_date, r.office_id, r.fd_product_id + """, Long.class, businessDate, headOfficeId, businessDate, businessDate); + } + + /** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */ + public void recordPostingFailure(final Long reportId, final String reason) { + this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report SET failure_reason = ? WHERE id = ?", + reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId); + } + + // ── reading ────────────────────────────────────────────────────────────────────────────────── + + /** + * Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for + * a head office user, is every other branch (or one, with officeId). canAct says whether this user + * may regenerate the report, canApprove whether they may approve it, and blockedReason why not. + */ + public List> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId, + final String scope, final AppUser user) { + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && this.branchAuthority.isHeadOffice(userOffice); + final Long branch = others ? officeId : userOffice; + + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.fd_product_id AS "productId", p.name AS "productName", r.fd_type AS "fdType", + r.office_id AS "officeId", o.name AS "officeName", r.posting_date AS "postingDate", r.status, + r.total_interest AS "totalInterest", r.account_count AS "accountCount", + r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy", + r.generated_by AS "generatedById", r.approved_at AS "approvedAt", au.username AS "approvedBy", + r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason", + %s AS "postingPeriodLabel" + FROM m_fd_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser gu ON gu.id = r.generated_by + LEFT JOIN m_appuser au ON au.id = r.approved_by + WHERE 1 = 1 + """.formatted(POSTING_PERIOD_LABEL_CASE)); + final List params = new ArrayList<>(); + if (postingDate != null) { + sql.append(" AND r.posting_date = ?"); + params.add(postingDate); + } + if (status != null && !status.isBlank()) { + sql.append(" AND r.status = ?"); + params.add(status.trim().toUpperCase()); + } else { + sql.append(" AND r.status <> 'SUPERSEDED'"); + } + if (productId != null) { + sql.append(" AND r.fd_product_id = ?"); + params.add(productId); + } + if (branch != null) { + sql.append(" AND r.office_id = ?"); + params.add(branch); + } + if (others) { + sql.append(" AND r.office_id <> ?"); + params.add(userOffice); + } + sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC"); + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final List> rows = new ArrayList<>(); + for (final Map row : this.jdbcTemplate.queryForList(sql.toString(), params.toArray())) { + final Map out = new LinkedHashMap<>(row); + final Object reportOffice = row.get("officeId"); + final String blocked = reportOffice == null ? "No branch on this report." + : actBlockedReason(userOffice, ((Number) reportOffice).longValue(), businessDate); + out.put("canAct", blocked == null); + out.put("blockedReason", blocked); + // The maker cannot be the checker, and the screen says so before the button is pressed. + final Object generatedById = row.get("generatedById"); + final boolean ownWork = generatedById != null && user != null && ((Number) generatedById).longValue() == user.getId(); + out.put("canApprove", blocked == null && !ownWork && GENERATED.equals(row.get("status"))); + out.put("approveBlockedReason", + ownWork ? "You generated this report — another user has to approve it." : blocked); + out.remove("generatedById"); + rows.add(out); + } + return rows; + } + + public Map detail(final Long reportId) { + final List> found = this.jdbcTemplate.queryForList(""" + SELECT r.id, r.fd_product_id AS "productId", p.name AS "productName", r.fd_type AS "fdType", + r.office_id AS "officeId", o.name AS "officeName", r.posting_date AS "postingDate", r.status, + r.total_interest AS "totalInterest", r.account_count AS "accountCount", + r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy", + r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf", + r.posted_at AS "postedAt", r.failure_reason AS "failureReason", + pg.gl_code AS "payableGlCode", pg.name AS "payableGlName", + dg.gl_code AS "paidGlCode", dg.name AS "paidGlName", + %s AS "postingPeriodLabel" + FROM m_fd_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser gu ON gu.id = r.generated_by + LEFT JOIN m_appuser au ON au.id = r.approved_by + LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id + LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id + WHERE r.id = ? + """.formatted(POSTING_PERIOD_LABEL_CASE), reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.not.found", + "Fixed deposit interest posting report " + reportId + " does not exist."); + } + final Map report = new LinkedHashMap<>(found.get(0)); + report.put("lines", this.jdbcTemplate.queryForList(""" + SELECT l.fd_account_id AS "fdAccountId", l.account_no AS "accountNo", l.client_name AS "clientName", + l.interest_period_from AS "interestPeriodFrom", + l.interest_posting_date AS "interestPostingDate", l.interest_amount AS "interestAmount", + l.balance_before AS "balanceBefore", l.balance_after AS "balanceAfter", + l.deposit_amount AS "depositAmount", l.interest_rate AS "interestRate", l.maturity_date AS "maturityDate", + l.payout_savings_account_id AS "payoutSavingsAccountId", l.payout_account_no AS "payoutAccountNo", + l.payout_transaction_id AS "payoutTransactionId", l.fd_transaction_id AS "fdTransactionId" + FROM m_fd_interest_posting_report_line l + WHERE l.report_id = ? + ORDER BY l.interest_posting_date, l.account_no + """, reportId)); + return report; + } + + /** + * What each fixed deposit has earned and not been paid, as at the business date — what it would be + * paid if interest were posted today. Read-only: nothing is saved, approved or posted. + * + *

+ * A branch user sees their own branch; a head office user every branch, or one with officeId. + */ + public List> preview(final Long officeId, final AppUser user) { + final Long userOffice = officeOf(user); + final Long branch = this.branchAuthority.isHeadOffice(userOffice) ? officeId : userOffice; + final List> rows = new ArrayList<>(); + for (final DueLine line : computeDueInterest(DateUtils.getBusinessLocalDate(), branch, null)) { + final Map row = new LinkedHashMap<>(); + row.put("fdAccountId", line.accountId()); + row.put("accountNo", line.accountNo()); + row.put("clientName", line.clientName()); + row.put("officeId", line.officeId()); + row.put("officeName", officeName(line.officeId())); + row.put("productId", line.productId()); + row.put("productName", productName(line.productId())); + row.put("fdType", line.fdType()); + row.put("interestPeriodFrom", line.periodFrom() == null ? null : line.periodFrom().toString()); + row.put("interestPostingDate", line.postingDate() == null ? null : line.postingDate().toString()); + row.put("depositAmount", line.depositAmount()); + row.put("interestRate", line.interestRate()); + row.put("maturityDate", line.maturityDate() == null ? null : line.maturityDate().toString()); + row.put("balance", line.balanceBefore()); + row.put("interest", line.interest().setScale(MONEY_SCALE, MONEY_ROUNDING)); + row.put("payoutAccountNo", line.payoutAccountNo()); + rows.add(row); + } + return rows; + } + + /** + * Branch and product interest for the head office: per report, how many deposits and how much + * interest, and where it stands. Superseded reports are left out. + */ + public List> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) { + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName", + r.fd_product_id AS "productId", p.name AS "productName", r.fd_type AS "fdType", + r.account_count AS "accountCount", r.total_interest AS "totalInterest", r.status, + au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf", + r.approved_at AS "approvedAt", r.posted_at AS "postedAt", + %s AS "postingPeriodLabel" + FROM m_fd_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser au ON au.id = r.approved_by + WHERE r.status <> 'SUPERSEDED' + """.formatted(POSTING_PERIOD_LABEL_CASE)); + final List params = new ArrayList<>(); + if (fromDate != null) { + sql.append(" AND r.posting_date >= ?"); + params.add(fromDate); + } + if (toDate != null) { + sql.append(" AND r.posting_date <= ?"); + params.add(toDate); + } + if (productId != null) { + sql.append(" AND r.fd_product_id = ?"); + params.add(productId); + } + sql.append(" ORDER BY r.posting_date DESC, p.name, o.name"); + return this.jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + /** + * Branch and product pairs with deposit interest due by the date that have no report yet — the + * noon job ran before they had anything due, or failed. + */ + public List> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) { + final List> out = new ArrayList<>(); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + return out; + } + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && this.branchAuthority.isHeadOffice(userOffice); + final List due = computeDueInterest(postingDate, others ? null : userOffice, null); + for (final Map.Entry> group : groupByProductAndOffice(due).entrySet()) { + final DueKey key = group.getKey(); + if (others == key.officeId().equals(userOffice)) { + continue; + } + if (activeReportId(key.productId(), key.officeId(), postingDate) != null) { + continue; + } + BigDecimal total = BigDecimal.ZERO; + for (final DueLine line : group.getValue()) { + total = total.add(line.interest()); + } + final Map row = new LinkedHashMap<>(); + row.put("productId", key.productId()); + row.put("productName", productName(key.productId())); + row.put("fdType", group.getValue().get(0).fdType()); + row.put("officeId", key.officeId()); + row.put("officeName", officeName(key.officeId())); + row.put("postingDate", postingDate.toString()); + row.put("accountCount", group.getValue().size()); + row.put("totalInterest", total.setScale(MONEY_SCALE, MONEY_ROUNDING)); + final String blocked = actBlockedReason(userOffice, key.officeId(), businessDate); + row.put("canAct", blocked == null); + row.put("blockedReason", blocked); + out.add(row); + } + return out; + } + + /** + * Fixed deposit interest posted without ever going through this report — chiefly a deposit maturing + * before its own posting cycle completed, whose last (and only its last) period is settled directly + * by the maturity job rather than reported and approved. Read-only, for visibility: a branch should + * be able to see every rupee of deposit interest it has paid, not only the part that was approved. + * + *

+ * Found by what it is NOT, rather than by naming the maturity job specifically: any interest + * posting transaction with no report line pointing at it. That also catches a posting from any + * other direct path this or a future change might open, rather than a list that quietly goes stale + * the day a second such path appears. + * + * @param officeId + * one branch, or null for every branch (or every branch's own, under scope) + */ + public List> outsideApproval(final LocalDate fromDate, final LocalDate toDate, final Long officeId, + final AppUser user, final String scope) { + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && this.branchAuthority.isHeadOffice(userOffice); + final Long branch = others ? officeId : userOffice; + + final StringBuilder sql = new StringBuilder(""" + SELECT sat.id AS "transactionId", sa.id AS "fdAccountId", sa.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "clientName", + COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName", + sa.product_id AS "productId", sp.name AS "productName", sp.fd_type AS "fdType", + sat.transaction_date AS "interestFor", sat.amount AS "interestAmount", + sat.created_on_utc AS "postedAt" + FROM m_savings_account_transaction sat + JOIN m_savings_account sa ON sa.id = sat.savings_account_id + JOIN m_savings_product sp ON sp.id = sa.product_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE sat.transaction_type_enum = 3 AND sa.deposit_type_enum = 200 AND sat.is_reversed = false + AND NOT EXISTS (SELECT 1 FROM m_fd_interest_posting_report_line l WHERE l.fd_transaction_id = sat.id) + """); + final List params = new ArrayList<>(); + if (fromDate != null) { + sql.append(" AND sat.transaction_date >= ?"); + params.add(fromDate); + } + if (toDate != null) { + sql.append(" AND sat.transaction_date <= ?"); + params.add(toDate); + } + if (branch != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(branch); + } else if (others) { + sql.append(" AND COALESCE(c.office_id, g.office_id) <> ?"); + params.add(userOffice); + } + sql.append(" ORDER BY sat.transaction_date DESC, sa.account_no"); + return this.jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + // ── who may act ────────────────────────────────────────────────────────────────────────────── + + /** + * Null where the user may generate or approve the branch's report, otherwise why not. The branch + * rules are the savings ones: a branch always acts for itself, and the head office acts for + * another branch only when that branch has not opened at all and every branch that did open has + * closed. + */ + private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + if (userOffice == null) { + return "No branch is set for this user."; + } + if (userOffice.equals(reportOffice)) { + return null; + } + final String branch = officeName(reportOffice); + if (!this.branchAuthority.isHeadOffice(userOffice)) { + return "Only " + branch + " can generate or approve its own fixed deposit interest posting report."; + } + if (this.branchAuthority.branchOpenedOn(reportOffice, businessDate)) { + return branch + " is open — it generates and approves its own report."; + } + final List stillOpen = this.branchAuthority.branchesStillOpen(businessDate, userOffice, reportOffice); + if (!stillOpen.isEmpty()) { + return "The head office can act for " + branch + " only after every open branch has closed. Still open: " + + String.join(", ", stillOpen) + "."; + } + return null; + } + + /** @return true where the head office is acting on a branch's behalf */ + private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) { + final Long userOffice = officeOf(user); + final String blocked = actBlockedReason(userOffice, reportOffice, businessDate); + if (blocked != null) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.other.branch", blocked); + } + return !reportOffice.equals(userOffice); + } + + public boolean isHeadOffice(final Long officeId) { + return this.branchAuthority.isHeadOffice(officeId); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) { + final List ids = this.jdbcTemplate.queryForList(""" + SELECT id FROM m_fd_interest_posting_report + WHERE fd_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + """, Long.class, productId, officeId, postingDate); + return ids.isEmpty() ? null : ids.get(0); + } + + private String productName(final Long productId) { + return this.jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId); + } + + private String officeName(final Long officeId) { + return this.jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId); + } + + private static Long officeOf(final AppUser user) { + return user == null || user.getOffice() == null ? null : user.getOffice().getId(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java new file mode 100644 index 000000000..33ba4630e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java @@ -0,0 +1,527 @@ +/** + * 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.portfolio.savings.fdinterest; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Pays an approved fixed deposit interest posting report. + * + *

+ * Nothing is recomputed here. The report's lines are the authority: each deposit is credited exactly + * what was approved for it, and interest that accrued after the report was generated is not added, + * because the approver signed off on these figures and not on others. The payable and paid accounts + * are those recorded on the report when it was generated. + * + *

+ * The ledger entries are the three stages fixed deposit interest has always posted through, raised + * once for the whole report rather than once per deposit — the same pooling the savings interest + * posting report uses, for the same reason: a bank's interest cost is one figure in its books, not + * one line per customer repeated three times over. + * + *

+ *   Dr  Interest Expense          (report total)  →  Cr  Fixed Deposit Interest Payable
+ *   Dr  Interest Payable          (report total)  →  Cr  Fixed Deposit Interest Paid
+ *   Dr  Interest Paid             (report total, once)
+ *   Cr  Savings Control           (per deposit, naming the account and its own amount)
+ * 
+ * + * A product that names no paid account posts the two-stage form, and one that names neither falls + * back to the plain Dr Expense / Cr Control pair — pooled the same way, one expense debit against many + * credits. Where one report holds deposits due for more than one period — a deposit owed two unpaid + * cycles, or a branch posting a stale cycle alongside today's — each {@code interestPostingDate} + * pools on its own: the chain above is raised once per date present on the report, not once overall, + * because the narration and the ledger date belong to the cycle, not to the day it happened to post. + * + *

+ * Where the interest then goes is the product's scheme, and it is not decided here. A + * {@code SPECIAL} deposit carries {@code transfer_interest_to_linked_account}, and the existing + * transfer-interest-to-savings job sweeps the posted interest to the customer's linked savings + * account as it always has; {@code SINGLE} and {@code DOUBLE} keep it in the deposit. Posting is the + * same act for all three — the money reaches the customer's deposit — so this service does one thing + * and the sweep is left where it already works. + * + *

+ * A deposit can mature between its report being generated and being posted, and a matured deposit's + * last period is settled directly by the maturity job the moment it matures — see + * {@code FixedDepositAccount#postMaturityInterest}, which this report's generation step cannot see + * coming and does not block on. Posting checks for that first: a line whose deposit already carries an + * interest posting for its exact date is linked to what actually paid it rather than paid again, and + * its amount is reconciled off the report's approved total rather than failing the whole posting over + * money that was never going to move twice. + * + *

+ * The whole report settles in one transaction: a failure leaves nobody half paid and the report still + * APPROVED for the next attempt. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class FixedDepositInterestPostingService { + + private static final int MONEY_SCALE = 2; + private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + + /** m_savings_account_transaction.transaction_type_enum */ + private static final int INTEREST_POSTING = 3; + /** acc_gl_journal_entry.entity_type_enum for a savings entry. */ + private static final int ENTITY_TYPE_SAVINGS = 2; + /** {@code CashAccountsForSavings.SAVINGS_CONTROL} */ + private static final int GL_SAVINGS_CONTROL = 2; + /** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense interest is charged to. */ + private static final int GL_INTEREST_ON_SAVINGS = 3; + /** {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product. */ + private static final int GL_INTEREST_PAYABLE = 17; + + /** + * The reserved names a deposit product may map its payable and paid accounts under, most specific + * first. The same lists the scheduler reads, so a product configured for the old path needs no + * change for the new one. + */ + private static final String[] PAYABLE_NAMES = { "Fixed Deposit Interest Payable", "Savings Interest Payable", + "Recurring Deposit Interest Payable", "Interest Payable", "Interest Payable Account" }; + private static final String[] PAID_NAMES = { "Fixed Deposit Interest Paid", "Savings Interest Paid", + "Recurring Deposit Interest Paid", "Interest Paid", "Interest Paid Account" }; + + private final JdbcTemplate jdbcTemplate; + + /** The payable and paid accounts a product's interest passes through. */ + public record PostingGlAccounts(Long payableGl, Long paidGl) {} + + /** + * One deposit's credit, after its transaction and balances are written but before the pooled ledger + * legs are raised — grouped with the others of its {@code interestDate} so the whole group is posted + * as one chain. + */ + private record PaidLine(Long accountId, BigDecimal amount, LocalDate interestDate, Long transactionId) {} + + /** + * What one line's posting came to: either a new credit to pool with the rest of its date-group, or + * an amount that turned out to be settled already — a deposit that matured, and had this exact + * period's interest posted directly by the maturity job, between this report being generated and + * being posted. {@code paid} is null in that case, and {@code reconciledAway} carries the amount so + * the report's approved total can be reconciled down by it rather than the whole posting failing a + * balance check over money that was never going to move twice. + */ + private record DepositOutcome(PaidLine paid, BigDecimal reconciledAway) {} + + /** + * The payable and paid accounts as the product maps them now, read when a report is generated so a + * product missing one is refused before anybody approves figures that could never be posted. + * + *

+ * Unlike savings, neither is required: a deposit product that names no payable posts the plain two + * legs it always did, and nulls here say so rather than stopping the report. + */ + public PostingGlAccounts resolvePostingGlAccounts(final long productId) { + if (optionalMappedGlAccount(productId, GL_INTEREST_ON_SAVINGS) == null) { + final String productName = this.jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, + productId); + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.gl.not.mapped", + "Fixed deposit product '" + productName + "' has no Interest On Savings GL mapped. Set it under the product's" + + " Accounting and generate the report again."); + } + Long payableGl = optionalMappedGlAccount(productId, GL_INTEREST_PAYABLE); + if (payableGl == null) { + payableGl = namedGlAccount(productId, PAYABLE_NAMES); + } + return new PostingGlAccounts(payableGl, namedGlAccount(productId, PAID_NAMES)); + } + + /** + * Posts one approved report, crediting each deposit exactly what was approved for it. + * + *

+ * Everything is dated the day it is actually posted — the business date — not the cycle's end: a + * branch that was shut on its posting date posts when it next closes, into a day that is still + * open, and the narration names the date the interest is for. + * + * @return the amount distributed + */ + @Transactional + public BigDecimal postReport(final long reportId) { + final List> reports = this.jdbcTemplate.queryForList(""" + SELECT id, fd_product_id, fd_type, office_id, posting_date, status, total_interest, payable_gl_account_id, + paid_gl_account_id + FROM m_fd_interest_posting_report + WHERE id = ? + FOR UPDATE + """, reportId); + if (reports.isEmpty()) { + throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " does not exist"); + } + final Map report = reports.get(0); + if (!FixedDepositInterestPostingReportService.APPROVED.equals(report.get("status"))) { + throw new IllegalStateException( + "Fixed deposit interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED"); + } + final long productId = ((Number) report.get("fd_product_id")).longValue(); + final long officeId = ((Number) report.get("office_id")).longValue(); + final LocalDate interestFor = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + final LocalDate postedOn = DateUtils.getBusinessLocalDate(); + final BigDecimal approved = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING); + + final List> lines = this.jdbcTemplate.queryForList(""" + SELECT l.id AS line_id, l.fd_account_id, l.interest_posting_date, l.interest_amount, l.fd_transaction_id, + sa.currency_code + FROM m_fd_interest_posting_report_line l + JOIN m_savings_account sa ON sa.id = l.fd_account_id + WHERE l.report_id = ? + ORDER BY l.interest_posting_date, l.fd_account_id + """, reportId); + if (lines.isEmpty()) { + throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " has no deposits to pay"); + } + + final long runId = openRun(reportId, productId, officeId, interestFor, lines.size()); + + final Long expenseGl = mappedGlAccount(productId, GL_INTEREST_ON_SAVINGS); + final Long controlGl = mappedGlAccount(productId, GL_SAVINGS_CONTROL); + final PostingGlAccounts current = resolvePostingGlAccounts(productId); + final Long payableGl = report.get("payable_gl_account_id") != null ? ((Number) report.get("payable_gl_account_id")).longValue() + : current.payableGl(); + final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue() + : current.paidGl(); + + final String currency = (String) lines.get(0).get("currency_code"); + // One per report, and deterministic: a retried posting carries the same reference as the legs a + // failed attempt rolled back, and two branches posting the same product never share one. + final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.FD_INTEREST_POSTED, postedOn, reportId); + + // Each deposit's transaction and balances are settled first; the ledger is raised after, pooled + // by the date its interest belongs to, so a report spanning two cycles raises two chains rather + // than crediting one period's money under the other's date. + final Map> byDate = new LinkedHashMap<>(); + BigDecimal distributed = BigDecimal.ZERO; + BigDecimal reconciledAway = BigDecimal.ZERO; + for (final Map line : lines) { + final DepositOutcome outcome = payDeposit(line, officeId, postedOn); + if (outcome.paid() != null) { + byDate.computeIfAbsent(outcome.paid().interestDate(), key -> new ArrayList<>()).add(outcome.paid()); + distributed = distributed.add(outcome.paid().amount()); + } + reconciledAway = reconciledAway.add(outcome.reconciledAway()); + } + + // A line reconciled away was never going to move twice: its amount is taken off what this + // posting is expected to distribute rather than failing the whole report over money that is + // already in the customer's deposit by another, older path. What was actually paid here still + // has to add up exactly to what is left. + final BigDecimal expected = approved.subtract(reconciledAway); + if (expected.compareTo(distributed) != 0) { + throw new IllegalStateException( + String.format("Fixed deposit interest posting report %d did not balance: approved %s, reconciled away %s," + + " distributed %s", reportId, approved, reconciledAway, distributed)); + } + + for (final Map.Entry> group : byDate.entrySet()) { + postPooledLedgerLegs(officeId, currency, group.getKey(), postedOn, group.getValue(), reference, expenseGl, payableGl, paidGl, + controlGl); + } + + completeReport(reportId, productId, interestFor, approved, distributed, runId, lines.size()); + return distributed; + } + + /** + * One deposit's interest transaction and balances, settled without raising any ledger entry — the + * caller pools this with the rest of its date-group before the GL is touched. + * + *

+ * The transaction carries the date the interest engine gave the period, not the day the branch is + * closing. That is not presentation: the engine decides a period has already been paid by looking + * for an interest transaction on exactly that date, so a posting written under the close date would + * be invisible to it and the same period would be computed and paid again at the next run. The + * close date is kept as the submitted-on stamp, which is what the scheduler has always done. + * + *

+ * Checked first against the deposit's own transactions for one this report did not put there: a + * deposit can mature between its report being generated and being posted, and the maturity job + * settles a matured deposit's last period itself, directly, the moment it matures — see + * {@code FixedDepositAccount#postMaturityInterest}. Where that has already happened for this exact + * date, the line is linked to what actually paid it rather than paying it again. + */ + private DepositOutcome payDeposit(final Map line, final long officeId, final LocalDate postedOn) { + final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING); + final long accountId = ((Number) line.get("fd_account_id")).longValue(); + final long lineId = ((Number) line.get("line_id")).longValue(); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return new DepositOutcome(null, BigDecimal.ZERO); + } + final LocalDate interestDate = ((java.sql.Date) line.get("interest_posting_date")).toLocalDate(); + + // A resumed or duplicated attempt must not pay anybody twice. The line itself records the + // payment, so this holds even where one deposit has two periods on one report. + if (line.get("fd_transaction_id") != null) { + throw new IllegalStateException("Fixed deposit " + accountId + " was already paid by this report"); + } + + final Long existing = findExistingInterestPosting(accountId, interestDate); + if (existing != null) { + log.warn( + "FD interest report line {}: deposit {} already has an interest posting for {} (transaction {}) not raised by this" + + " report — most likely matured in the meantime. Linking to it instead of posting again.", + lineId, accountId, interestDate, existing); + this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", existing, lineId); + return new DepositOutcome(null, amount); + } + + // The balance as it stands now, not as the report showed it: money may have moved since the + // report was generated, and the transaction's running balance has to follow the deposit. + final BigDecimal currentBalance = this.jdbcTemplate.queryForObject( + "SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId); + final BigDecimal newBalance = currentBalance.add(amount); + + final long transactionId = createInterestPostingTransaction(accountId, officeId, interestDate, postedOn, amount, newBalance); + + // Earned is only ever raised to what has now been posted, never added to: the engine may + // already have counted this interest as earned, and adding on top of that doubles it. Screens + // read unposted interest as earned - posted, so earned must not fall below posted either. + // + // interest_posted_till_date is what takes the deposit out of the next report's candidates; the + // interest transaction itself is what stops the engine recomputing the same period. + this.jdbcTemplate.update(""" + UPDATE m_savings_account + SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, + total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?, + total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0), + COALESCE(total_interest_posted_derived, 0) + ?), + interest_posted_till_date = GREATEST(COALESCE(interest_posted_till_date, ?), ?) + WHERE id = ? + """, amount, amount, amount, interestDate, interestDate, accountId); + + this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", transactionId, + lineId); + + return new DepositOutcome(new PaidLine(accountId, amount, interestDate, transactionId), BigDecimal.ZERO); + } + + /** A non-reversed interest posting this deposit already carries for this exact date, if any. */ + private Long findExistingInterestPosting(final long accountId, final LocalDate interestDate) { + final List found = this.jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_type_enum = ? AND transaction_date = ? AND is_reversed = false + ORDER BY id DESC LIMIT 1 + """, Long.class, accountId, INTEREST_POSTING, interestDate); + return found.isEmpty() ? null : found.get(0); + } + + /** + * One date-group's chain, pooled: the report's cost moves through the fewer-or-more intermediate + * accounts the product's mappings support as one figure, and only the last leg — crediting each + * deposit — is raised per account. The group's own total is what moves, not the report's, so two + * periods on one report each carry their own entries under their own date. + */ + private void postPooledLedgerLegs(final long officeId, final String currency, final LocalDate interestDate, final LocalDate postedOn, + final List group, final String reference, final Long expenseGl, final Long payableGl, final Long paidGl, + final Long controlGl) { + final String narration = "Fixed deposit interest for " + interestDate; + BigDecimal total = BigDecimal.ZERO; + for (final PaidLine paid : group) { + total = total.add(paid.amount()); + } + + final boolean hasPayable = payableGl != null && payableGl != 0 && !payableGl.equals(controlGl); + final boolean hasPaid = paidGl != null && paidGl != 0 && !paidGl.equals(payableGl); + + // The chain of pooled accounts the group's cost passes through before it is split: Expense, then + // Payable and Paid where the product maps them — each consecutive pair raised once for the whole + // total. The last account in the chain is what Stage 2 debits before crediting each deposit. + final List chain = new ArrayList<>(); + chain.add(expenseGl); + if (hasPayable) { + chain.add(payableGl); + if (hasPaid) { + chain.add(paidGl); + } + } + for (int i = 0; i < chain.size() - 1; i++) { + journalEntry(officeId, currency, chain.get(i), interestDate, postedOn, total, true, reference, null, null, narration); + journalEntry(officeId, currency, chain.get(i + 1), interestDate, postedOn, total, false, reference, null, null, narration); + } + + // Stage 2: one debit for the group's total, and under it a credit to each deposit. Every leg + // carries the report's one transaction id, and every credit names its account and interest + // transaction, so who was paid what reads back from the journal alone. + journalEntry(officeId, currency, chain.get(chain.size() - 1), interestDate, postedOn, total, true, reference, null, null, + narration); + for (final PaidLine paid : group) { + journalEntry(officeId, currency, controlGl, interestDate, postedOn, paid.amount(), false, reference, paid.accountId(), + paid.transactionId(), narration); + } + } + + private void completeReport(final long reportId, final long productId, final LocalDate interestFor, final BigDecimal approved, + final BigDecimal distributed, final long runId, final int accountCount) { + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_run + SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0, + status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, approved, distributed, runId); + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_report + SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL + WHERE id = ? + """, runId, reportId); + log.info("FD interest posting: report {} (product {}, for {}) paid {} across {} deposits", reportId, productId, interestFor, + distributed, accountCount); + } + + /** One run per report. A retry of the same report reuses its run. */ + private long openRun(final long reportId, final long productId, final long officeId, final LocalDate postingDate, + final int accountCount) { + this.jdbcTemplate.update(""" + INSERT INTO m_fd_interest_posting_run + (report_id, fd_product_id, office_id, posting_date, account_count, status) + VALUES (?, ?, ?, ?, ?, 'PENDING') + ON CONFLICT (report_id) DO UPDATE SET + account_count = EXCLUDED.account_count, status = 'PENDING', + started_at = CURRENT_TIMESTAMP, failure_reason = NULL + """, reportId, productId, officeId, postingDate, accountCount); + return this.jdbcTemplate.queryForObject("SELECT id FROM m_fd_interest_posting_run WHERE report_id = ?", Long.class, reportId); + } + + /** + * @param interestDate + * the period the interest is for, which is the transaction's own date + * @param postedOn + * the business date it is being written on, kept as the submitted-on stamp + */ + private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate interestDate, + final LocalDate postedOn, final BigDecimal amount, final BigDecimal runningBalance) { + this.jdbcTemplate.update(""" + INSERT INTO m_savings_account_transaction + (savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction, + transaction_type_enum, transaction_date, amount, running_balance_derived, + submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc) + VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, accountId, officeId, INTEREST_POSTING, interestDate, amount, runningBalance, postedOn, systemUserId(), + systemUserId()); + return this.jdbcTemplate.queryForObject(""" + SELECT id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ? + ORDER BY id DESC LIMIT 1 + """, Long.class, accountId, interestDate, INTEREST_POSTING); + } + + /** + * One journal entry leg. Fineract's {@code type_enum} is 2 = DEBIT, 1 = CREDIT — {@code + * JournalEntryType} declares {@code CREDIT(1)} first. + * + *

+ * Dated the period the interest is for, as the scheduler dates its own legs, with the business date + * kept as the submitted-on stamp. + */ + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate interestDate, + final LocalDate postedOn, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, + final Long transactionId, final String narration) { + GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId); + this.jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, + amount, description, entity_type_enum, entity_id, is_running_balance_calculated, + savings_transaction_id, reversed, manual_entry, office_running_balance, + organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date, + created_on_utc, last_modified_on_utc, submitted_on_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) + """, officeId, glAccountId, currency, reference, interestDate, interestDate, debit ? 2 : 1, amount, narration, + savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(), + postedOn); + } + + /** + * The user these rows are attributed to. A day close has a user, but the nightly retry does not and + * the audit columns are NOT NULL, so entries are stamped to the system account either way — as the + * scheduler's own postings always were. + */ + private Long systemUserId() { + // id 2 is the seeded system user; its username is per-tenant now, so it is found by id + final List ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class); + return ids.isEmpty() ? 1L : ids.get(0); + } + + private Long mappedGlAccount(final long productId, final int financialAccountType) { + final Long found = optionalMappedGlAccount(productId, financialAccountType); + if (found == null) { + throw new IllegalStateException("Fixed deposit product " + productId + " has no GL account mapped for financial account type " + + financialAccountType + ". Set the product's Accounting tab before its interest can be posted."); + } + return found; + } + + private Long optionalMappedGlAccount(final long productId, final int financialAccountType) { + final List found = this.jdbcTemplate.queryForList(""" + SELECT gl_account_id FROM acc_product_mapping + WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? + LIMIT 1 + """, Long.class, productId, financialAccountType); + return found.isEmpty() ? null : found.get(0); + } + + /** + * The GL account a product maps under one of the reserved names in {@code other_gl_mappings}, tried + * in order of preference. The column holds an empty string on a product saved with no named + * mappings and casting that to jsonb is an error, so the cast sits inside a subquery that has + * already filtered those rows out. + */ + private Long namedGlAccount(final long productId, final String... accountNames) { + for (final String accountName : accountNames) { + final List found = this.jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?)) + LIMIT 1 + """, Long.class, productId, accountName); + if (!found.isEmpty()) { + return found.get(0); + } + } + return null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java new file mode 100644 index 000000000..70d90e6ec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.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.portfolio.savings.fdinterest.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +/** + * The fixed deposit interest posting reports, per branch and product: list, detail, generate, approve, + * the day-close check, and the head office's branch-wise summary. + * + *

+ * Posting is not offered here. A branch's approved reports are posted when that branch closes the + * day, so there is exactly one way interest reaches a customer's deposit. + */ +@Path("/v1/fixeddepositinterestpostingreports") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class FixedDepositInterestPostingReportApiResource { + + private static final String RESOURCE = "FDINTERESTREPORT"; + + private final FixedDepositInterestPostingReportService reportService; + private final ConfigurationDomainService configurationDomainService; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + /** A branch user sees their own branch; a head office user every branch, or one with officeId. */ + @GET + public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status, + @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId, + @QueryParam("scope") final String scope) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return this.gson + .toJson(this.reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream() + .map(this::plain).toList()); + } + + /** + * Who is looking: their branch, whether it is the head office (which gets the Other branches tab), + * and whether deposits are on the approval path at all — with the switch off every screen here is + * empty by design, and the UI should say so rather than look broken. + */ + @GET + @Path("/context") + public String userContext() { + final AppUser user = this.context.authenticatedUser(); + final Long officeId = user.getOffice().getId(); + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + result.put("officeName", user.getOffice().getName()); + result.put("headOffice", this.reportService.isHeadOffice(officeId)); + result.put("approvalEnabled", this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()); + return this.gson.toJson(result); + } + + /** Branch and product pairs with deposit interest due by the date and no report yet. */ + @GET + @Path("/due-without-report") + public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + return this.gson.toJson(this.reportService.dueWithoutReport(date, user, scope)); + } + + /** + * What stops a branch closing the business day: its own deposit reports due and not yet approved, + * and the products and amounts behind them. The day-close screen reads it to warn before the close + * is attempted. + */ + @GET + @Path("/day-close-check") + public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) { + final AppUser user = this.context.authenticatedUser(); + final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date); + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final Map view = this.reportService.dayCloseInterestView(officeId, businessDate); + @SuppressWarnings("unchecked") + final List blocking = (List) view.get("pending"); + final Map result = new LinkedHashMap<>(); + result.put("businessDate", businessDate.toString()); + result.put("canClose", blocking.isEmpty()); + result.put("pendingProducts", blocking); + result.put("due", view.get("due")); + return this.gson.toJson(result); + } + + /** What each deposit has earned and not been paid, as if posted today. View only — nothing is saved. */ + @GET + @Path("/preview") + public String preview(@QueryParam("officeId") final Long officeId) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return this.gson.toJson(this.reportService.preview(officeId, user).stream().map(this::plain).toList()); + } + + /** The head office's view: per branch and product, deposits paid and interest, over a date range. */ + @GET + @Path("/summary") + public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("productId") final Long productId) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + if (!this.reportService.isHeadOffice(user.getOffice().getId())) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.summary.head.office.only", + "The branch-wise fixed deposit interest summary is for the head office."); + } + return this.gson + .toJson(this.reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList()); + } + + /** + * Deposit interest posted without this report ever seeing it — chiefly a deposit maturing before + * its posting cycle completed, whose last period a different, older path settles directly. Here so + * a branch can see every rupee of deposit interest it has paid, approved or not. + */ + @GET + @Path("/outside-approval") + public String outsideApproval(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("officeId") final Long officeId, @QueryParam("scope") final String scope) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return this.gson.toJson( + this.reportService.outsideApproval(parseDate(fromDate), parseDate(toDate), officeId, user, scope).stream() + .map(this::plain).toList()); + } + + @GET + @Path("/{reportId}") + public String detail(@PathParam("reportId") final Long reportId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE); + final Map report = plain(this.reportService.detail(reportId)); + @SuppressWarnings("unchecked") + final List> lines = (List>) report.get("lines"); + report.put("lines", lines.stream().map(this::plain).toList()); + return this.gson.toJson(report); + } + + /** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + public String command(@QueryParam("command") final String command, final String body) { + if (!"regenerate".equalsIgnoreCase(command)) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.unknown.command", "Unsupported command: " + command); + } + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("REGENERATE_" + RESOURCE); + final var root = this.fromJsonHelper.parse(body); + final Long productId = this.fromJsonHelper.extractLongNamed("productId", root); + final Long officeIdParam = this.fromJsonHelper.extractLongNamed("officeId", root); + final String postingDate = this.fromJsonHelper.extractStringNamed("postingDate", root); + if (productId == null) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.product.required", "Which product?"); + } + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + final Long reportId = this.reportService.regenerate(productId, officeId, date, user); + return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.GENERATED)); + } + + /** + * {@code ?command=approve}. A user of the report's branch who did not generate it approves it; see + * the service for the head office's one exception. + */ + @POST + @Path("/{reportId}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) { + if (!"approve".equalsIgnoreCase(command)) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.unknown.command", "Unsupported command: " + command); + } + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("APPROVE_" + RESOURCE); + this.reportService.approve(reportId, user); + return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.APPROVED)); + } + + private static LocalDate parseDate(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return LocalDate.parse(raw.trim()); + } catch (final RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.invalid.date", + "Dates are expected as yyyy-MM-dd, not " + raw); + } + } + + /** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */ + private Map plain(final Map row) { + final Map out = new LinkedHashMap<>(); + row.forEach((key, value) -> { + if (value instanceof java.sql.Date d) { + out.put(key, d.toLocalDate().toString()); + } else if (value instanceof Timestamp t) { + out.put(key, t.toLocalDateTime().toString()); + } else { + out.put(key, value); + } + }); + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportConfig.java new file mode 100644 index 000000000..2c1a812cd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportConfig.java @@ -0,0 +1,52 @@ +/** + * 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.portfolio.savings.fdinterest.jobs; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +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.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class FixedDepositInterestPostingReportConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step fdInterestPostingReportStep(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) { + return new StepBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository) + .tasklet(fdInterestPostingReportTasklet, transactionManager).build(); + } + + @Bean + public Job fdInterestPostingReportJob(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) { + return new JobBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository) + .start(fdInterestPostingReportStep(fdInterestPostingReportTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportTasklet.java new file mode 100644 index 000000000..7f5bb3fba --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportTasklet.java @@ -0,0 +1,57 @@ +/** + * 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.portfolio.savings.fdinterest.jobs; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * Noon on the business date: generates the interest posting report for every fixed deposit product + * whose interest falls due, so a branch can review and approve it before it closes the day. + * + *

+ * Does nothing while deposits are off the approval path — the posting job is paying them directly, + * and a report generated alongside it would be approved against money that has already moved. + */ +@RequiredArgsConstructor +@Component +@Slf4j +public class FixedDepositInterestPostingReportTasklet implements Tasklet { + + private final FixedDepositInterestPostingReportService reportService; + private final ConfigurationDomainService configurationDomainService; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) throws Exception { + if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) { + log.info("FD interest posting report: skipped — fixed deposit interest is posted directly by the posting job"); + return RepeatStatus.FINISHED; + } + this.reportService.generateDue(DateUtils.getBusinessLocalDate()); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 0b4d85c11..14b776752 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -36,6 +36,7 @@ import java.util.List; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.common.AccountingRuleType; import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.data.EnumOptionData; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.domain.JdbcSupport; @@ -109,16 +110,24 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; private final SavingsDailyInterestService savingsDailyInterestService; + private final ConfigurationDomainService configurationDomainService; + + /** m_savings_account.status_enum for an active account. */ + private static final Integer ACTIVE_STATUS = 300; + /** m_savings_product.deposit_type_enum for a fixed deposit. */ + private static final Integer DEPOSIT_TYPE_FIXED = 200; public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, - final SavingsDailyInterestService savingsDailyInterestService) { + final SavingsDailyInterestService savingsDailyInterestService, + final ConfigurationDomainService configurationDomainService) { this.context = context; this.jdbcTemplate = jdbcTemplate; this.sqlGenerator = sqlGenerator; this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; this.savingsDailyInterestService = savingsDailyInterestService; + this.configurationDomainService = configurationDomainService; this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); this.transactionsMapper = new SavingsAccountTransactionsMapper(); this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); @@ -442,10 +451,18 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead // 300) is left out: its posting periods are credited by the nightly deposit maturity job instead // (RecurringDepositAccount#postPeriodicInterest), at the chart rate and on deposits alone. The filters sit inside the paging subquery so a page is never cut short by // accounts that are then dropped. + // + // Fixed deposits come out on the same grounds once their interest goes through an approved + // report (FixedDepositInterestPostingReportService): the report's figures are posted when the + // branch closes its day, and this job running over the same deposits would pay them twice. + final String fixedDepositFilter = this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled() + ? "and a.deposit_type_enum <> " + DEPOSIT_TYPE_FIXED + " " + : ""; String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? and a.deposit_type_enum <> 300 " + "and not exists (select 1 from m_savings_interest_posting_schedule s " - + "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id "; + + "where s.savings_product_id = a.product_id and s.is_active = true) " + fixedDepositFilter + + "limit ?) b on b.id = sa.id "; if (backdatedTxnsAllowedTill) { sql = sql + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; @@ -463,6 +480,55 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead return savingsAccountDataList; } + /** + * The fixed deposits of one branch, and optionally one product, whose interest may be due on the + * posting date — the candidates an interest posting report is drawn from. + * + *

+ * "May be" is deliberate. A deposit is selected here on the same cheap test the posting job has + * always used, that its interest has not been posted past the day before, and whether a posting + * period has actually ended is then settled by the interest engine, which is the only thing that + * knows a product's compounding and posting periods. A candidate the engine finds nothing for + * simply contributes no line to the report. + * + *

+ * Unpaged, unlike the posting job: a report covers one branch and one product and is read in full + * before anybody approves it, so there is no page for a deposit to fall off the end of. + */ + @Override + public List retrieveFixedDepositsDueForInterestPosting(final boolean backdatedTxnsAllowedTill, + final LocalDate postingDate, final Long officeId, final Long productId) { + final StringBuilder candidates = new StringBuilder("select a.id from m_savings_account a ") + .append("left join m_client ic on ic.id = a.client_id ") + .append("left join m_group ig on ig.id = a.group_id ") + .append("where a.status_enum = ? and a.deposit_type_enum = ? ") + .append("and (a.interest_posted_till_date is null or a.interest_posted_till_date <= ?) "); + final List params = new ArrayList<>(List.of(ACTIVE_STATUS, DEPOSIT_TYPE_FIXED, postingDate.minusDays(1))); + if (officeId != null) { + candidates.append("and coalesce(ic.office_id, ig.office_id) = ? "); + params.add(officeId); + } + if (productId != null) { + candidates.append("and a.product_id = ? "); + params.add(productId); + } + + String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + "join (" + candidates + + ") b on b.id = sa.id "; + if (backdatedTxnsAllowedTill) { + sql = sql + + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; + } + sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id"; + + final List deposits = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR + params.toArray()); + for (final SavingsAccountData deposit : deposits) { + this.savingAccountAssembler.assembleSavings(deposit); + } + return deposits; + } + private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor> { private final String schemaSql; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 8a49c6734..5b012228b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -434,9 +434,9 @@ public class SavingsConfiguration { SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator, // @Lazy because the accrual service depends on beans from this same package: resolving // both eagerly closes a cycle and the context fails to start. - @Lazy SavingsDailyInterestService savingsDailyInterestService) { + @Lazy SavingsDailyInterestService savingsDailyInterestService, ConfigurationDomainService configurationDomainService) { return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator, - sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService); + sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService, configurationDomainService); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java index 16d140cb1..f23767e00 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java @@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRu import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; +import org.apache.fineract.infrastructure.core.service.PlatformEmailService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; @@ -82,6 +83,7 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat private final PasswordEncoder passwordEncoder; private final UserDataValidator fromApiJsonDeserializer; private final FromJsonHelper fromApiJsonHelper; + private final PlatformEmailService emailService; @Override @Transactional @@ -293,13 +295,40 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat appUser.updatePasswordResetRequired(true); } - // Set pre-hashed password + // AppUser.fromJson() already generated a random plaintext password here when the maker ticked + // "send password to email" (it ignores whatever password the maker typed in that case) -- the + // field still holds that plaintext at this point, since nothing has encoded it yet. If the + // maker instead supplied their own password, request.getPasswordHash() carries the bcrypt + // hash submitCreateUser() produced from it; that plaintext was redacted to "[PROTECTED]" + // immediately and is gone for good, so there is nothing to email in that case. + final boolean sendPasswordToEmail = Boolean.TRUE.equals(replay.booleanObjectValueOfParameterNamed("sendPasswordToEmail")); + final String unencodedPassword = appUser.getPassword(); + if (request.getPasswordHash() != null) { + // Maker-chosen password: already hashed, used as-is. appUser.updatePassword(request.getPasswordHash()); + } else { + // Auto-generated password (the normal "send password to email" case): this service's own + // generic PasswordEncoder, not the salted PlatformPasswordEncoder the live create-user flow + // uses -- consistent with how the maker-chosen-password branch above and submitCreateUser() + // already hash a password in this file. Encoding it here is not optional: AppUser's + // constructor stores whatever string it is given verbatim (see AppUser(Office, User, ...)), + // so skipping this would persist the plaintext password straight into m_appuser.password. + appUser.updatePassword(this.passwordEncoder.encode(unencodedPassword)); } this.appUserRepository.saveAndFlush(appUser); + // Mirrors JpaUserDomainService.create(): the welcome email the live create-user flow sends is + // the one thing this parallel, pending-request flow never did -- sendPasswordToEmail was + // accepted and stored in the payload but nothing here ever acted on it, so a user created this + // way never received their password. Only fires for the auto-generated password: there is no + // plaintext to send for a maker-chosen one, per the comment above. + if (sendPasswordToEmail && request.getPasswordHash() == null) { + this.emailService.sendToUserAccount(userOffice.getName(), appUser.getFirstname(), appUser.getEmail(), appUser.getUsername(), + unencodedPassword); + } + // MFA Enrollment final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod"); if (otpDeliveryMethod != null) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 279de1120..7ef1ca7f7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -172,4 +172,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml new file mode 100644 index 000000000..301180bf0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Generate Fixed Deposit Interest Posting Report' + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_FDINTERESTREPORT', + 'REGENERATE_FDINTERESTREPORT', 'APPROVE_FDINTERESTREPORT') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_FDINTERESTREPORT', 'REGENERATE_FDINTERESTREPORT', + 'APPROVE_FDINTERESTREPORT') + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_FDINTERESTREPORT', 'REGENERATE_FDINTERESTREPORT', + 'APPROVE_FDINTERESTREPORT') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'fd-interest-posting-approval' + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java b/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java index 4f38aadbf..11ae68a4a 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java +++ b/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java @@ -159,7 +159,9 @@ public class TestConfiguration { FineractProperties fineractProperties, Environment environment, List customTaskChangesForDependencyInjection) { return new TenantDatabaseUpgradeService(tenantDetailsService, tenantDataSource, fineractProperties, tenantDatabaseStateVerifier, - liquibaseFactory, tenantDataSourceFactory, environment, customTaskChangesForDependencyInjection); + liquibaseFactory, tenantDataSourceFactory, environment, + new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment), + customTaskChangesForDependencyInjection); } /** diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java index a22499905..621bbe96e 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java @@ -189,6 +189,8 @@ public class LiquibaseStepDefinitions implements En { .willReturn(tenantLiquibase); tenantDatabaseUpgradeService = new TenantDatabaseUpgradeService(tenantDetailsService, tenantStoreDataSource, fineractProperties, - databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment, Arrays.asList(tenantPasswordEncryptor)); + databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment, + new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment), + Arrays.asList(tenantPasswordEncryptor)); } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java index cf408c534..dc2229636 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java @@ -68,6 +68,18 @@ public interface SavingsAccountReadPlatformService { List retrieveAllSavingsDataForInterestPosting(boolean backdatedTxnsAllowedTill, int pageSize, Integer status, Long maxSavingsId); + /** + * The active fixed deposits of one branch — and one product, where given — whose interest may be + * due for posting on the date. The interest engine settles whether any actually is. + * + * @param officeId + * the branch the deposit's customer belongs to, or null for every branch + * @param productId + * one fixed deposit product, or null for every product + */ + List retrieveFixedDepositsDueForInterestPosting(boolean backdatedTxnsAllowedTill, LocalDate postingDate, + Long officeId, Long productId); + List retrieveAllTransactionData(List refNo); Long retrieveAccountIdByExternalId(ExternalId externalId);