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:
+ *
+ *
+ *
{@link #SINGLE} — short deposits, priced from a slab table in days. Interest stays in the
+ * deposit and is paid with the principal at maturity.
+ *
{@link #DOUBLE} — deposits of a year or more, priced from a slab table in years. Interest is
+ * compounded back into the deposit, so the maturity value is principal plus interest.
+ *
{@link #SPECIAL} — the monthly income scheme. Interest is posted monthly and swept out to the
+ * customer's linked savings account, which is why a special deposit cannot be opened without one;
+ * the deposit itself matures at its principal, the interest having already been paid away.
+ *
+ *
+ *
+ * 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