> rows = jdbcTemplate
+ .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
+ if (rows.isEmpty() || rows.get(0).get("value") == null) {
+ return BigDecimal.ZERO;
+ }
+ return new BigDecimal(String.valueOf(rows.get(0).get("value")));
+ }
+
+ private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) {
+ return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
+ }
+
+ private WebApplicationException apiError(final int httpStatus, final String message) {
+ return new WebApplicationException(
+ Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java
new file mode 100644
index 000000000..abae1f99f
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java
@@ -0,0 +1,180 @@
+package org.apache.fineract.accounting.yearend.api;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Financial year-end closing of the share entry fee into the Bank Profit equity GL.
+ *
+ *
+ * {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
+ * performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
+ * this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
+ */
+@Path("/v1/accounting/incomeprofittransfer")
+@Component
+@RequiredArgsConstructor
+@Consumes({ MediaType.APPLICATION_JSON })
+@Produces({ MediaType.APPLICATION_JSON })
+public class IncomeProfitTransferApiResource {
+
+ private final PlatformSecurityContext context;
+ private final JdbcTemplate jdbcTemplate;
+ private final IncomeProfitTransferService service;
+ private final Gson gson = new Gson();
+
+ // ══════════════════════════════════════════════════════════════════════
+ // GET — how much is waiting to be posted, and what has been posted before
+ // ══════════════════════════════════════════════════════════════════════
+ @GET
+ public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
+ @QueryParam("financialYear") final String financialYearParam) {
+
+ this.context.authenticatedUser();
+
+ final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
+ ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
+ : incomeGlCodeParam.trim();
+ final Map incomeGl = service.loadGlByCode(incomeGlCode);
+ final Map profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
+
+ final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
+ : financialYearParam.trim();
+ final LocalDate[] range = service.financialYearRange(financialYear);
+
+ final Long incomeGlId = toLong(incomeGl.get("id"));
+ final BigDecimal pending = service.balanceOf(incomeGlId, range);
+ final int entryCount = service.entryCountOf(incomeGlId, range);
+
+ final List> history = jdbcTemplate
+ .queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
+ + " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
+ + " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
+ + " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
+
+ final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
+
+ final Map out = new LinkedHashMap<>();
+ out.put("financialYear", financialYear);
+ out.put("fromDate", range[0].toString());
+ out.put("toDate", range[1].toString());
+ out.put("incomeGlCode", incomeGl.get("gl_code"));
+ out.put("incomeGlName", incomeGl.get("name"));
+ out.put("profitGlCode", profitGl.get("gl_code"));
+ out.put("profitGlName", profitGl.get("name"));
+ out.put("pendingAmount", pending);
+ out.put("entryCount", entryCount);
+ out.put("alreadyPosted", alreadyPosted);
+ out.put("postedHistory", history);
+ out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // POST — close the income GL into Bank Profit
+ // ══════════════════════════════════════════════════════════════════════
+ @POST
+ @Transactional
+ public Response post(final String body) {
+
+ final AppUser currentUser = this.context.authenticatedUser();
+ currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
+
+ final JsonObject json = parseBody(body);
+ final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
+ : getString(json, "incomeGlCode").trim();
+ final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
+ : getString(json, "financialYear").trim();
+
+ // strict = true: a manual posting reports "already closed" or "nothing to post" as an
+ // error, so the user is told why nothing happened.
+ final Map result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
+ sanitiseNote(getString(json, "note")), true);
+
+ return Response.ok(gson.toJson(result)).build();
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // POST /close-year — close the WHOLE P&L into Bank Profit
+ // ══════════════════════════════════════════════════════════════════════
+
+ /**
+ * Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
+ *
+ * This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
+ * share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
+ * has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
+ */
+ @POST
+ @Path("/close-year")
+ @Transactional
+ public Response closeYear(final String body) {
+
+ final AppUser currentUser = this.context.authenticatedUser();
+ currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
+
+ final JsonObject json = parseBody(body);
+ final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
+ : getString(json, "financialYear").trim();
+
+ // strict = true: a manual close reports "nothing left to close" as an error, so the user is
+ // told why nothing happened rather than getting a silent success.
+ final Map result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
+ sanitiseNote(getString(json, "note")), true);
+
+ return Response.ok(gson.toJson(result)).build();
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // Helpers
+ // ══════════════════════════════════════════════════════════════════════
+
+ private JsonObject parseBody(final String body) {
+ if (body == null || body.isBlank()) {
+ return new JsonObject();
+ }
+ try {
+ return JsonParser.parseString(body).getAsJsonObject();
+ } catch (RuntimeException e) {
+ throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
+ }
+ }
+
+ private String sanitiseNote(final String raw) {
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ final String trimmed = raw.trim();
+ return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
+ }
+
+ private String getString(final JsonObject json, final String field) {
+ return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
+ }
+
+ private Long toLong(final Object o) {
+ return o instanceof Number ? ((Number) o).longValue() : null;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java
new file mode 100644
index 000000000..d237b8c5b
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java
@@ -0,0 +1,60 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
+
+import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
+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 PostShareEntryFeeToProfitConfig {
+
+ @Autowired
+ private JobRepository jobRepository;
+ @Autowired
+ private PlatformTransactionManager transactionManager;
+ @Autowired
+ private IncomeProfitTransferService incomeProfitTransferService;
+
+ @Bean
+ protected Step postShareEntryFeeToProfitStep() {
+ return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
+ .tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
+ }
+
+ @Bean
+ public Job postShareEntryFeeToProfitJob() {
+ return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
+ .incrementer(new RunIdIncrementer()).build();
+ }
+
+ @Bean
+ public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
+ return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java
new file mode 100644
index 000000000..4f12d5628
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java
@@ -0,0 +1,61 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
+
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
+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;
+
+/**
+ * Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
+ * Profit equity GL, leaving the income account at nil for the new year.
+ *
+ *
+ * Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
+ * rather than failing the job. Only a genuine posting failure is raised.
+ */
+@Slf4j
+@RequiredArgsConstructor
+public class PostShareEntryFeeToProfitTasklet implements Tasklet {
+
+ /** Fineract's system user id, used because a scheduled run has no logged-in user. */
+ private static final long SYSTEM_USER_ID = 1L;
+
+ private final IncomeProfitTransferService incomeProfitTransferService;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
+ final String financialYear = incomeProfitTransferService.currentFinancialYear();
+
+ // Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
+ // is now what the share dividend declaration reads as "the year's profit", and a partial close
+ // would leave most of the profit still sitting in the income GLs — understating what may be
+ // distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
+ final Map result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
+ "Automatic year-end closing", false);
+
+ log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java
new file mode 100644
index 000000000..464ddee50
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java
@@ -0,0 +1,377 @@
+package org.apache.fineract.accounting.yearend.service;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+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.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Closes an income GL into the Bank Profit equity GL at financial year end.
+ *
+ *
+ * Dr Share Entry Fee Income (10.4) = balance for the year
+ * Cr Bank Profit (BANKPROFIT01)
+ *
+ *
+ * Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
+ * retained profit.
+ *
+ *
+ * Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
+ * protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
+ * logged-in user.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class IncomeProfitTransferService {
+
+ // Fineract convention: 1 = CREDIT, 2 = DEBIT.
+ private static final int DEBIT = 2;
+ private static final int CREDIT = 1;
+
+ /** Equity GL that accumulates profit. */
+ /**
+ * The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
+ *
+ * A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
+ * the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years'
+ * leftovers, and the next declaration would be sized on profit that had already been through a distribution
+ * decision — offering the same money for distribution twice.
+ */
+ public static final String PROFIT_GL_CODE = "BANKPROFITND01";
+
+ /** Income GL closed to profit by the year-end job — the share entry fee. */
+ public static final String DEFAULT_INCOME_GL_CODE = "10.4";
+
+ private final JdbcTemplate jdbcTemplate;
+
+ /**
+ * Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
+ * there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
+ * errors.
+ *
+ * @param strict
+ * when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
+ * returns quietly.
+ */
+ @Transactional
+ public Map postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
+ final String note, final boolean strict) {
+
+ final Map incomeGl = loadGlByCode(incomeGlCode);
+ final Map profitGl = loadGlByCode(PROFIT_GL_CODE);
+ final Long incomeGlId = toLong(incomeGl.get("id"));
+ final Long profitGlId = toLong(profitGl.get("id"));
+
+ final LocalDate[] range = financialYearRange(financialYear);
+ final Long officeId = headOfficeId();
+
+ final Map out = new LinkedHashMap<>();
+ out.put("financialYear", financialYear);
+ out.put("incomeGlCode", incomeGl.get("gl_code"));
+ out.put("incomeGlName", incomeGl.get("name"));
+ out.put("profitGlCode", profitGl.get("gl_code"));
+ out.put("profitGlName", profitGl.get("name"));
+
+ final Integer existing = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
+ Integer.class, financialYear, incomeGlId, officeId);
+ if (existing != null && existing > 0) {
+ if (strict) {
+ throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
+ incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
+ }
+ out.put("posted", false);
+ out.put("reason", "Already closed for " + financialYear);
+ return out;
+ }
+
+ final BigDecimal balance = balanceOf(incomeGlId, range);
+ if (balance.compareTo(BigDecimal.ZERO) <= 0) {
+ if (strict) {
+ throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
+ "There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
+ }
+ out.put("posted", false);
+ out.put("reason", "No income to close for " + financialYear);
+ return out;
+ }
+
+ final int entryCount = entryCountOf(incomeGlId, range);
+ final String currencyCode = resolveCurrency(incomeGlId);
+ final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final LocalDate postingDate = range[1];
+ final String txnId = "YEPROFIT-" + System.nanoTime();
+ final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ + (note != null ? " | " + note : "");
+
+ insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
+ insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
+ "Profit for " + financialYear + " from " + incomeGl.get("name"));
+
+ jdbcTemplate.update(
+ "INSERT INTO m_income_profit_transfer "
+ + "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ + " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
+ entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
+
+ log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
+ financialYear);
+
+ out.put("posted", true);
+ out.put("amountPostedToProfit", balance);
+ out.put("entryCount", entryCount);
+ out.put("postedOnDate", postingDate.toString());
+ out.put("glTransactionId", txnId);
+ out.put("currency", currencyCode);
+ out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
+ return out;
+ }
+
+ /**
+ * Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
+ * net profit.
+ *
+ *
+ * each income GL: Dr Income GL Cr Bank Profit (income raises profit)
+ * each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
+ *
+ *
+ * {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
+ * the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
+ * the share dividend declaration in particular — depends on this having run, because a partial close leaves the
+ * profit split across two places and understates it.
+ *
+ * Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
+ * re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
+ * Accounts with a nil balance are skipped rather than posted as zero entries.
+ */
+ @Transactional
+ public Map closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
+ final boolean strict) {
+
+ final Map profitGl = loadGlByCode(PROFIT_GL_CODE);
+ final Long profitGlId = toLong(profitGl.get("id"));
+ final LocalDate[] range = financialYearRange(financialYear);
+ final Long officeId = headOfficeId();
+
+ // 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
+ // historical balance but must not attract new postings.
+ final List> plAccounts = jdbcTemplate.queryForList(
+ "SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
+ + " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
+
+ final List> closed = new ArrayList<>();
+ final List> skipped = new ArrayList<>();
+ BigDecimal totalIncome = BigDecimal.ZERO;
+ BigDecimal totalExpense = BigDecimal.ZERO;
+
+ for (final Map gl : plAccounts) {
+ final Long glId = toLong(gl.get("id"));
+ final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
+
+ final Integer existing = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
+ Integer.class, financialYear, glId, officeId);
+ if (existing != null && existing > 0) {
+ skipped.add(skipRow(gl, "Already closed for " + financialYear));
+ continue;
+ }
+
+ // balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
+ // expense GL the natural balance is a debit, so the same figure comes back negative and
+ // is flipped here to read as a positive expense.
+ final BigDecimal rawBalance = balanceOf(glId, range);
+ final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
+ if (balance.compareTo(BigDecimal.ZERO) <= 0) {
+ skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
+ continue;
+ }
+
+ final int entryCount = entryCountOf(glId, range);
+ final String currencyCode = resolveCurrency(glId);
+ final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final LocalDate postingDate = range[1];
+ final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
+ final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ + (note != null ? " | " + note : "");
+
+ if (isIncome) {
+ insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
+ insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
+ "Profit for " + financialYear + " from " + gl.get("name"));
+ totalIncome = totalIncome.add(balance);
+ } else {
+ insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
+ insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
+ "Expense for " + financialYear + " from " + gl.get("name"));
+ totalExpense = totalExpense.add(balance);
+ }
+
+ jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
+ + "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ + " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
+ java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
+ java.sql.Date.valueOf(postingDate), userId, userName, note);
+
+ final Map row = new LinkedHashMap<>();
+ row.put("glCode", gl.get("gl_code"));
+ row.put("glName", gl.get("name"));
+ row.put("type", isIncome ? "INCOME" : "EXPENSE");
+ row.put("amount", balance);
+ row.put("entryCount", entryCount);
+ row.put("glTransactionId", txnId);
+ closed.add(row);
+ }
+
+ if (closed.isEmpty() && strict) {
+ throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
+ "There is nothing left to close to profit for " + financialYear + ".");
+ }
+
+ final BigDecimal netProfit = totalIncome.subtract(totalExpense);
+ log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
+ netProfit, profitGl.get("name"));
+
+ final Map out = new LinkedHashMap<>();
+ out.put("financialYear", financialYear);
+ out.put("fromDate", range[0].toString());
+ out.put("toDate", range[1].toString());
+ out.put("profitGlCode", profitGl.get("gl_code"));
+ out.put("profitGlName", profitGl.get("name"));
+ out.put("totalIncomeClosed", totalIncome);
+ out.put("totalExpenseClosed", totalExpense);
+ out.put("netProfitPosted", netProfit);
+ out.put("accountsClosed", closed);
+ out.put("accountsSkipped", skipped);
+ out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
+ + netProfit + ".");
+ return out;
+ }
+
+ private Map skipRow(final Map gl, final String reason) {
+ final Map row = new LinkedHashMap<>();
+ row.put("glCode", gl.get("gl_code"));
+ row.put("glName", gl.get("name"));
+ row.put("reason", reason);
+ return row;
+ }
+
+ /**
+ * The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
+ * year has been closed.
+ *
+ * Only entries dated inside the financial year count, so each year's profit is separable rather than being one
+ * ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
+ * whether that is a blocking condition.
+ */
+ public BigDecimal profitForFinancialYear(final String financialYear) {
+ final LocalDate[] range = financialYearRange(financialYear);
+ final Map profitGl = loadGlByCode(PROFIT_GL_CODE);
+ final BigDecimal balance = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
+ BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
+ return balance == null ? BigDecimal.ZERO : balance;
+ }
+
+ /** Whether the year's P&L has been closed into Bank Profit at all. */
+ public boolean isYearClosed(final String financialYear) {
+ final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
+ Integer.class, financialYear);
+ return count != null && count > 0;
+ }
+
+ /** Net credit balance sitting in the income GL for the year. */
+ public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
+ final BigDecimal balance = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
+ BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
+ return balance == null ? BigDecimal.ZERO : balance;
+ }
+
+ /** How many fee postings made up that balance. */
+ public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
+ final Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
+ + " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
+ Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
+ return count == null ? 0 : count;
+ }
+
+ /** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
+ public String currentFinancialYear() {
+ final LocalDate today = DateUtils.getBusinessLocalDate();
+ final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
+ return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
+ }
+
+ /** 1 April to 31 March for a "YYYY-YY" label. */
+ public LocalDate[] financialYearRange(final String financialYear) {
+ try {
+ final int startYear = Integer.parseInt(financialYear.substring(0, 4));
+ return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
+ } catch (RuntimeException e) {
+ throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
+ "financialYear must look like 2026-27. Received: " + financialYear);
+ }
+ }
+
+ public Map loadGlByCode(final String glCode) {
+ try {
+ return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
+ } catch (EmptyResultDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
+ }
+ }
+
+ public Long headOfficeId() {
+ return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
+ }
+
+ private String resolveCurrency(final Long glAccountId) {
+ try {
+ final String code = jdbcTemplate.queryForObject(
+ "SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
+ glAccountId);
+ return code == null ? "INR" : code;
+ } catch (EmptyResultDataAccessException e) {
+ return "INR";
+ }
+ }
+
+ private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
+ final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
+ jdbcTemplate.update(
+ "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ + " entry_date, type_enum, amount, description, "
+ + " created_by, last_modified_by, created_date, lastmodified_date, "
+ + " is_running_balance_calculated, office_running_balance, "
+ + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
+ glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
+ now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
+ now.atOffset(ZoneOffset.UTC));
+ }
+
+ private Long toLong(final Object o) {
+ return o instanceof Number ? ((Number) o).longValue() : null;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java
new file mode 100644
index 000000000..3d78e5787
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java
@@ -0,0 +1,299 @@
+/**
+ * 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.accountregister.api;
+
+import com.google.gson.Gson;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * Account register for one date: which accounts were opened and which were closed that day, with
+ * each account's balance as it stood on that date alongside what it holds now.
+ *
+ * The dated balance is read from the running balance stamped on the last transaction up to that
+ * date, rather than recomputed from the account's current figure — a current balance tells you
+ * nothing about what the account held three weeks ago.
+ */
+@Path("/v1/accountregister")
+@Component
+@RequiredArgsConstructor
+@Produces({ MediaType.APPLICATION_JSON })
+public class AccountRegisterApiResource {
+
+ private final JdbcTemplate jdbcTemplate;
+ private final PlatformSecurityContext context;
+ private final Gson gson = new Gson();
+
+ @GET
+ public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
+ this.context.authenticatedUser();
+
+ final LocalDate asOn;
+ if (dateParam != null && !dateParam.isBlank()) {
+ try {
+ asOn = LocalDate.parse(dateParam);
+ } catch (final java.time.format.DateTimeParseException e) {
+ throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
+ "Date must be in yyyy-MM-dd format.");
+ }
+ } else {
+ asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
+ }
+
+ final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
+ final List> rows = new ArrayList<>();
+
+ if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
+ rows.addAll(depositAccounts(asOn, want));
+ }
+ if (matches(want, "SHARE")) {
+ rows.addAll(shareAccounts(asOn));
+ }
+
+ // Opened and closed counts come from the rows themselves rather than separate COUNT queries,
+ // so the totals can never disagree with the list they summarise.
+ int opened = 0;
+ int closed = 0;
+ BigDecimal balanceOnDate = BigDecimal.ZERO;
+ BigDecimal currentBalance = BigDecimal.ZERO;
+ for (final Map r : rows) {
+ if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
+ opened++;
+ }
+ if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
+ closed++;
+ }
+ balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
+ currentBalance = currentBalance.add(dec(r.get("currentBalance")));
+ }
+
+ final Map out = new LinkedHashMap<>();
+ out.put("date", asOn.toString());
+ out.put("section", want);
+ out.put("accounts", rows);
+ out.put("count", rows.size());
+ out.put("openedCount", opened);
+ out.put("closedCount", closed);
+ out.put("totalBalanceOnDate", balanceOnDate);
+ out.put("totalCurrentBalance", currentBalance);
+ return gson.toJson(out);
+ }
+
+ private static boolean matches(final String want, final String section) {
+ return "ALL".equals(want) || want.equals(section);
+ }
+
+ private static BigDecimal dec(final Object o) {
+ return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
+ }
+
+ /**
+ * Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
+ * apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
+ *
+ * Accounts submitted after the date are excluded — on that date they did not exist, and listing
+ * them would make a register of a past day describe a present one.
+ */
+ private List> depositAccounts(final LocalDate asOn, final String want) {
+ final Date date = Date.valueOf(asOn);
+
+ final StringBuilder typeFilter = new StringBuilder();
+ if (!"ALL".equals(want)) {
+ final int type = switch (want) {
+ case "SAVINGS" -> 100;
+ case "FD" -> 200;
+ case "RD" -> 300;
+ default -> 0;
+ };
+ if (type > 0) {
+ typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
+ }
+ }
+
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
+ sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
+ sa.account_balance_derived,
+ c.display_name AS client_name,
+ p.name AS product_name,
+ (SELECT t.running_balance_derived
+ FROM m_savings_account_transaction t
+ WHERE t.savings_account_id = sa.id
+ AND COALESCE(t.is_reversed, false) = false
+ AND t.transaction_date <= ?
+ ORDER BY t.transaction_date DESC, t.id DESC
+ LIMIT 1) AS balance_on_date
+ FROM m_savings_account sa
+ LEFT JOIN m_client c ON c.id = sa.client_id
+ LEFT JOIN m_savings_product p ON p.id = sa.product_id
+ WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
+ """ + typeFilter + """
+ ORDER BY sa.deposit_type_enum, sa.account_no
+ """, date, date);
+
+ final List> out = new ArrayList<>();
+ for (final Map r : rows) {
+ final Map m = new LinkedHashMap<>();
+ final int depositType = num(r.get("deposit_type_enum"));
+ m.put("accountId", r.get("id"));
+ m.put("accountNo", r.get("account_no"));
+ m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
+ m.put("clientName", r.get("client_name"));
+ m.put("productName", r.get("product_name"));
+ m.put("status", savingsStatus(num(r.get("status_enum"))));
+ m.put("openedOn", str(r.get("activatedon_date")));
+ m.put("closedOn", str(r.get("closedon_date")));
+ m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
+ m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
+ // No transaction up to that date means the account had nothing in it yet, not that the
+ // balance is unknown.
+ m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
+ m.put("currentBalance", r.get("account_balance_derived") == null
+ ? BigDecimal.ZERO : r.get("account_balance_derived"));
+ out.add(m);
+ }
+ return out;
+ }
+
+ /**
+ * Share accounts. Their transactions carry no running balance the way savings ones do, but they
+ * are individually dated, so the holding on a given date is the shares purchased up to that
+ * date less those redeemed — which is a real dated figure, not the current one relabelled.
+ */
+ private List> shareAccounts(final LocalDate asOn) {
+ final Date date = Date.valueOf(asOn);
+
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
+ sa.total_approved_shares,
+ c.display_name AS client_name,
+ p.name AS product_name,
+ (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
+ WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
+ ELSE 0 END), 0)
+ FROM m_share_account_transactions t
+ WHERE t.account_id = sa.id AND t.is_active = true
+ AND t.status_enum = 300
+ AND t.type_enum IN (500, 600)) AS share_value,
+ -- Purchases less redemptions dated on or before the register's date. Charge
+ -- rows (type 700) are excluded: an entry fee is not share capital. Only
+ -- approved purchases count (status 300) — a rejected application leaves its
+ -- row behind still flagged active, and counting it credited a member with
+ -- shares they were never issued.
+ (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
+ WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
+ ELSE 0 END), 0)
+ FROM m_share_account_transactions t
+ WHERE t.account_id = sa.id AND t.is_active = true
+ AND t.status_enum = 300
+ AND t.type_enum IN (500, 600)
+ AND t.transaction_date <= ?) AS value_on_date,
+ (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
+ WHEN t.type_enum = 600 THEN -t.total_shares
+ ELSE 0 END), 0)
+ FROM m_share_account_transactions t
+ WHERE t.account_id = sa.id AND t.is_active = true
+ AND t.status_enum = 300
+ AND t.type_enum IN (500, 600)
+ AND t.transaction_date <= ?) AS shares_on_date
+ FROM m_share_account sa
+ LEFT JOIN m_client c ON c.id = sa.client_id
+ LEFT JOIN m_share_product p ON p.id = sa.product_id
+ WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
+ ORDER BY sa.account_no
+ """, date, date, date);
+
+ final List> out = new ArrayList<>();
+ for (final Map r : rows) {
+ final Map m = new LinkedHashMap<>();
+ m.put("accountId", r.get("id"));
+ m.put("accountNo", r.get("account_no"));
+ m.put("section", "SHARE");
+ m.put("clientName", r.get("client_name"));
+ m.put("productName", r.get("product_name"));
+ m.put("status", shareStatus(num(r.get("status_enum"))));
+ m.put("openedOn", str(r.get("activated_date")));
+ m.put("closedOn", str(r.get("closed_date")));
+ m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
+ m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
+ m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
+ m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
+ // Shares as at the date, not the approved figure — an account can hold fewer than were
+ // approved once some have been redeemed.
+ m.put("sharesHeld", r.get("shares_on_date"));
+ out.add(m);
+ }
+ return out;
+ }
+
+ private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
+ return dbDate != null && asOn.toString().equals(dbDate.toString());
+ }
+
+ private static String str(final Object o) {
+ return o == null ? null : o.toString();
+ }
+
+ private static int num(final Object o) {
+ return o instanceof Number n ? n.intValue() : 0;
+ }
+
+ private static String savingsStatus(final int status) {
+ return switch (status) {
+ case 100 -> "SUBMITTED";
+ case 200 -> "APPROVED";
+ case 300 -> "ACTIVE";
+ case 400 -> "WITHDRAWN";
+ case 500 -> "REJECTED";
+ case 600 -> "CLOSED";
+ case 700 -> "TRANSFER IN PROGRESS";
+ case 800 -> "TRANSFER ON HOLD";
+ case 900 -> "PREMATURELY CLOSED";
+ default -> "UNKNOWN";
+ };
+ }
+
+ private static String shareStatus(final int status) {
+ return switch (status) {
+ case 100 -> "SUBMITTED";
+ case 200 -> "APPROVED";
+ case 300 -> "ACTIVE";
+ case 400 -> "WITHDRAWN";
+ case 500 -> "REJECTED";
+ case 600 -> "CLOSED";
+ default -> "UNKNOWN";
+ };
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java
index 6792f9e6b..014ba7d8a 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
@@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
-@Transactional
+// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
+// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
+@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlFundTransferEvaluationService {
private static final int STRUCTURING_WINDOW_DAYS = 30;
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java
index 862213e83..843a436d5 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java
@@ -31,11 +31,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
+ * transaction that has already succeeded. Joining the caller's transaction would let a failed
+ * alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
+ * commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
+ * the shared transaction rather than thrown to them. A private transaction keeps AML failure
+ * contained to AML.
+ */
@Service
@RequiredArgsConstructor
-@Transactional
+@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
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
new file mode 100644
index 000000000..ae56cd0b9
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java
@@ -0,0 +1,477 @@
+/**
+ * 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.branchday.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.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+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.useradministration.domain.AppUser;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * Branch day register — the formal opening and closing of a branch's business day.
+ *
+ * A day that was never closed is the thing this exists to surface: while it stays open its balance
+ * cannot be carried forward reliably, a back-dated entry can still be posted into it, and any cash
+ * difference on that date goes unnoticed. The register records who opened and closed each day, and
+ * lists the ones still hanging.
+ *
+ * Closing a day late is allowed but never silent: it is stamped as a force-close with the reason it
+ * was left open, so "closed that evening" and "closed nine days later" stay distinguishable.
+ */
+@Path("/v1/branchday")
+@Component
+@RequiredArgsConstructor
+@Produces({ MediaType.APPLICATION_JSON })
+public class BranchDayRegisterApiResource {
+
+ private final JdbcTemplate jdbcTemplate;
+ private final PlatformSecurityContext context;
+ private final FromJsonHelper fromJsonHelper;
+ private final Gson gson = new Gson();
+
+ // ── GET /v1/branchday ── the register itself ──────────────────────────────
+
+ /**
+ * The register for one office, newest first. Defaults to the caller's own office so a branch
+ * user sees their own branch without having to know its id.
+ */
+ @GET
+ public String list(@QueryParam("officeId") final Long officeIdParam, @QueryParam("limit") final Integer limitParam) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ final Long officeId = resolveOffice(officeIdParam, currentUser);
+ final int limit = limitParam == null || limitParam < 1 ? 60 : Math.min(limitParam, 400);
+
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
+ r.opened_by_name, r.opened_at, r.opening_cash,
+ r.closed_by_name, r.closed_at, r.closing_cash,
+ r.book_cash, r.cash_difference,
+ r.force_closed, r.force_close_reason, r.remarks
+ FROM m_branch_day_register r
+ LEFT JOIN m_office o ON o.id = r.office_id
+ WHERE r.office_id = ?
+ ORDER BY r.business_date DESC
+ LIMIT ?
+ """, officeId, limit);
+
+ final Map result = new LinkedHashMap<>();
+ result.put("officeId", officeId);
+ result.put("canOperate", canOperateDay(currentUser));
+ result.put("days", mapDays(rows));
+ result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
+ result.put("openDay", findOpenDay(officeId));
+ return gson.toJson(result);
+ }
+
+ // ── GET /v1/branchday/missed ── days left open ────────────────────────────
+
+ /**
+ * Business days that were opened and never closed, oldest first — the ones that need attention
+ * most. Today's own open day is excluded: it is open because the day is still running, which is
+ * not a missed close.
+ */
+ @GET
+ @Path("missed")
+ public String missed(@QueryParam("officeId") final Long officeIdParam) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ final Long officeId = resolveOffice(officeIdParam, currentUser);
+ final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
+
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
+ r.opened_by_name, r.opened_at, r.opening_cash,
+ r.closed_by_name, r.closed_at, r.closing_cash,
+ r.book_cash, r.cash_difference,
+ r.force_closed, r.force_close_reason, r.remarks
+ FROM m_branch_day_register r
+ LEFT JOIN m_office o ON o.id = r.office_id
+ WHERE r.office_id = ? AND r.closed_at IS NULL AND r.business_date < ?
+ ORDER BY r.business_date ASC
+ """, officeId, Date.valueOf(today));
+
+ final List> days = mapDays(rows);
+ // Age in days is what makes the list actionable — a day open since last week is a different
+ // problem from one open since yesterday.
+ for (final Map d : days) {
+ final Object bd = d.get("businessDate");
+ d.put("daysOpen", bd == null ? null : java.time.temporal.ChronoUnit.DAYS.between(LocalDate.parse(bd.toString()), today));
+ }
+
+ return gson.toJson(Map.of("officeId", officeId, "canOperate", canOperateDay(currentUser), "missed", days,
+ "count", days.size()));
+ }
+
+ // ── GET /v1/branchday/close-preview ──────────────────────────────────────
+
+ /**
+ * What the day should close at, with the arithmetic that produces it, plus the denomination
+ * total the cashier actually keyed in during the day.
+ *
+ * Exists so nobody has to work the figure out by hand or type it from memory: the screen shows
+ * where every line came from, and offers the counted total as the closing figure. A day is
+ * still closed on what someone physically counted — this only removes the guesswork about what
+ * that count is supposed to match.
+ */
+ @GET
+ @Path("close-preview")
+ public String closePreview(@QueryParam("officeId") final Long officeIdParam,
+ @QueryParam("businessDate") final String businessDateParam) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ final Long officeId = resolveOffice(officeIdParam, currentUser);
+
+ final LocalDate businessDate;
+ if (businessDateParam != null && !businessDateParam.isBlank()) {
+ try {
+ businessDate = LocalDate.parse(businessDateParam);
+ } catch (final java.time.format.DateTimeParseException e) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
+ "Business date must be in yyyy-MM-dd format.");
+ }
+ } else {
+ final Map open = findOpenDay(officeId);
+ if (open == null) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
+ "No business day is open for this branch.");
+ }
+ businessDate = LocalDate.parse(String.valueOf(open.get("businessDate")));
+ }
+
+ final Map result = new LinkedHashMap<>(bookBreakdown(officeId, businessDate));
+ result.put("officeId", officeId);
+ result.put("businessDate", businessDate.toString());
+
+ // What the cashier's own denomination entries add up to for the day. Where this and the
+ // expected figure agree, the day tallies and the closing figure is not in question.
+ final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
+ WHERE txn_date = ? AND txn_type = 'DEPOSIT'
+ """, BigDecimal.class, Date.valueOf(businessDate)));
+ final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
+ WHERE txn_date = ? AND txn_type = 'WITHDRAWAL'
+ """, BigDecimal.class, Date.valueOf(businessDate)));
+ result.put("denominationIn", countedIn);
+ result.put("denominationOut", countedOut);
+
+ return gson.toJson(result);
+ }
+
+ // ── POST /v1/branchday/open ───────────────────────────────────────────────
+
+ @POST
+ @Path("open")
+ @Consumes(MediaType.APPLICATION_JSON)
+ public String openDay(final String body) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ requireDayAuthority(currentUser);
+
+ final var root = this.fromJsonHelper.parse(body);
+ final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
+ final LocalDate businessDate = extractDate(root, "businessDate",
+ LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
+ final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
+
+ // A branch runs one day at a time. Opening a second while the first is still open is how a
+ // day gets stranded, so the previous one has to be closed first.
+ final Map open = findOpenDay(officeId);
+ if (open != null) {
+ final Object existing = open.get("businessDate");
+ if (businessDate.toString().equals(String.valueOf(existing))) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.already.open",
+ "Business day " + businessDate + " is already open for this branch.");
+ }
+ throw new PlatformDataIntegrityException("error.msg.branchday.previous.open",
+ "Business day " + existing + " is still open. Close it before opening " + businessDate + ".");
+ }
+
+ final Integer exists = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
+ Integer.class, officeId, Date.valueOf(businessDate));
+ if (exists != null && exists > 0) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
+ "Business day " + businessDate + " has already been opened and closed for this branch.");
+ }
+
+ // Yesterday's closing is today's opening unless the caller states otherwise, so the carried
+ // figure does not have to be retyped and cannot drift.
+ final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate);
+
+ final Long id = jdbcTemplate.queryForObject("""
+ INSERT INTO m_branch_day_register
+ (office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed)
+ VALUES (?,?,?,?,?,?,false)
+ RETURNING id
+ """,
+ Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(),
+ Timestamp.from(Instant.now()), carried);
+
+ return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(),
+ "openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN"));
+ }
+
+ // ── POST /v1/branchday/close ──────────────────────────────────────────────
+
+ @POST
+ @Path("close")
+ @Consumes(MediaType.APPLICATION_JSON)
+ public String closeDay(final String body) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ requireDayAuthority(currentUser);
+
+ final var root = this.fromJsonHelper.parse(body);
+ final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
+ final LocalDate businessDate = extractDate(root, "businessDate", null);
+ if (businessDate == null) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.date.required",
+ "Which business day is being closed?");
+ }
+ final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
+ final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
+ final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
+
+ final List> found = jdbcTemplate.queryForList(
+ "SELECT id, closed_at, opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
+ officeId, Date.valueOf(businessDate));
+ if (found.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
+ "Business day " + businessDate + " was never opened for this branch.");
+ }
+ if (found.get(0).get("closed_at") != null) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
+ "Business day " + businessDate + " is already closed.");
+ }
+
+ // Closing a past date is allowed — otherwise a day left open could never be settled at all —
+ // but it must say why it was left open, and it is stamped as a force close.
+ final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
+ final boolean isLate = businessDate.isBefore(today);
+ if (isLate && (forceReason == null || forceReason.isBlank())) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
+ "Business day " + businessDate + " is being closed late. Record why it was left open.");
+ }
+
+ final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
+ final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
+
+ jdbcTemplate.update("""
+ UPDATE m_branch_day_register
+ SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
+ book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
+ WHERE office_id = ? AND business_date = ?
+ """,
+ currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
+ bookCash, difference, isLate, isLate ? forceReason : null, remarks,
+ officeId, Date.valueOf(businessDate));
+
+ final Map res = new LinkedHashMap<>();
+ res.put("officeId", officeId);
+ res.put("businessDate", businessDate.toString());
+ res.put("status", "CLOSED");
+ res.put("closingCash", closingCash);
+ res.put("bookCash", bookCash);
+ res.put("cashDifference", difference);
+ res.put("forceClosed", isLate);
+ return gson.toJson(res);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private List> mapDays(final List> rows) {
+ final List> out = new ArrayList<>();
+ for (final Map r : rows) {
+ final Map m = new LinkedHashMap<>();
+ m.put("id", r.get("id"));
+ m.put("officeId", r.get("office_id"));
+ m.put("officeName", r.get("office_name"));
+ m.put("businessDate", str(r.get("business_date")));
+ m.put("openedByName", r.get("opened_by_name"));
+ m.put("openedAt", str(r.get("opened_at")));
+ m.put("openingCash", r.get("opening_cash"));
+ m.put("closedByName", r.get("closed_by_name"));
+ m.put("closedAt", str(r.get("closed_at")));
+ m.put("closingCash", r.get("closing_cash"));
+ m.put("bookCash", r.get("book_cash"));
+ m.put("cashDifference", r.get("cash_difference"));
+ m.put("forceClosed", r.get("force_closed"));
+ m.put("forceCloseReason", r.get("force_close_reason"));
+ m.put("remarks", r.get("remarks"));
+ m.put("status", r.get("closed_at") == null ? "OPEN" : "CLOSED");
+ out.add(m);
+ }
+ return out;
+ }
+
+ private static String str(final Object o) {
+ return o == null ? null : o.toString();
+ }
+
+ /** The branch's currently open day, or null when none is open. */
+ private Map findOpenDay(final Long officeId) {
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash
+ FROM m_branch_day_register
+ WHERE office_id = ? AND closed_at IS NULL
+ ORDER BY business_date ASC LIMIT 1
+ """, officeId);
+ if (rows.isEmpty()) {
+ return null;
+ }
+ final Map r = rows.get(0);
+ final Map m = new LinkedHashMap<>();
+ m.put("id", r.get("id"));
+ m.put("businessDate", str(r.get("business_date")));
+ m.put("openedByName", r.get("opened_by_name"));
+ m.put("openedAt", str(r.get("opened_at")));
+ m.put("openingCash", r.get("opening_cash"));
+ return m;
+ }
+
+ /** The most recent closing figure before this date, so it carries forward as the opening. */
+ private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) {
+ final List rows = jdbcTemplate.query("""
+ SELECT closing_cash FROM m_branch_day_register
+ WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL
+ ORDER BY business_date DESC LIMIT 1
+ """, (rs, n) -> rs.getBigDecimal(1), officeId, Date.valueOf(before));
+ return rows.isEmpty() ? BigDecimal.ZERO : rows.get(0);
+ }
+
+ /**
+ * Cash the books say the branch should be holding at the end of that date: what came in as
+ * approved cash deposits less what went out as approved cash withdrawals. Deliberately derived
+ * here and then stored on the row, so the figure the day was closed against survives any later
+ * reversal.
+ */
+ private Map bookBreakdown(final Long officeId, final LocalDate businessDate) {
+ final Date date = Date.valueOf(businessDate);
+
+ // The figure recorded when this very day was opened — not the previous day's closing.
+ // Deriving it from history alone ignored an opening balance keyed in by hand, which is
+ // exactly what happens on the first day a branch is put on the system: the day was opened
+ // with 20,000 in the drawer and the closing arithmetic started from zero.
+ final BigDecimal ownOpening = jdbcTemplate.query(
+ "SELECT opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
+ (rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
+ final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
+
+ final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
+ WHERE status = 'APPROVED'
+ AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
+ AND (office_id = ? OR office_id IS NULL)
+ """, BigDecimal.class, date, officeId));
+
+ final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request
+ WHERE status = 'APPROVED'
+ AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
+ """, BigDecimal.class, date));
+
+ // Cash drawn from the vault to a cashier — more physical cash sitting in the branch.
+ final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
+ WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false
+ """, BigDecimal.class, date));
+
+ // Cash a cashier settled back to the vault at the end of the day.
+ final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
+ WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false
+ """, BigDecimal.class, date));
+
+ final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut);
+
+ final Map out = new LinkedHashMap<>();
+ out.put("openingCash", opening);
+ out.put("deposits", deposits);
+ out.put("vaultIn", vaultIn);
+ out.put("withdrawals", withdrawals);
+ out.put("settledOut", settledOut);
+ out.put("expectedCash", expected);
+ return out;
+ }
+
+ private static BigDecimal zero(final BigDecimal v) {
+ return v == null ? BigDecimal.ZERO : v;
+ }
+
+ private Long resolveOffice(final Long requested, final AppUser user) {
+ if (requested != null) {
+ return requested;
+ }
+ return user.getOffice() != null ? user.getOffice().getId() : 1L;
+ }
+
+ private LocalDate extractDate(final com.google.gson.JsonElement root, final String field, final LocalDate fallback) {
+ final String raw = this.fromJsonHelper.extractStringNamed(field, root);
+ if (raw == null || raw.isBlank()) {
+ return fallback;
+ }
+ try {
+ return LocalDate.parse(raw);
+ } catch (final java.time.format.DateTimeParseException e) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
+ "Business date must be in yyyy-MM-dd format.");
+ }
+ }
+
+ /**
+ * Who may open or close a branch day: a clerk, head cashier or manager. The clerk carries the
+ * same authority as the manager here by design — a day left open because the only manager was
+ * away is exactly the situation this register exists to prevent.
+ *
+ * A cashier is excluded: the cashier works inside the day and must not be the one who declares
+ * it closed.
+ */
+ private boolean canOperateDay(final AppUser user) {
+ if (user.hasAnyPermission("ALL_FUNCTIONS")) {
+ return true;
+ }
+ return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
+ .anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk"));
+ }
+
+ private void requireDayAuthority(final AppUser user) {
+ if (!canOperateDay(user)) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.forbidden",
+ "Only a Clerk, Head Cashier or Bank Manager can open or close a business day.");
+ }
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java
index f0cb31fce..95e444476 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java
@@ -32,6 +32,179 @@ public class CashReportApiResource {
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
+ /**
+ * Day book — every voucher raised on one date, in one list, newest first.
+ *
+ * Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
+ * and the clerk disbursement register (DR, or CR for a collection). Each row carries its
+ * voucher number, side, amount, account, party and status, so the cashier can reconcile a day
+ * without opening three separate screens.
+ *
+ * Dated on the business date the voucher belongs to rather than the row's creation timestamp:
+ * deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
+ * has no such column — falls back to the date it was submitted on.
+ */
+ @GET
+ @Path("day-book")
+ public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
+ @QueryParam("status") final String status) {
+
+ final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
+
+ final List> rows = new ArrayList<>();
+
+ // Each register is left-joined to the verification table and to an attachment count, so a
+ // row already carries its physical-check verdict and whether a slip was scanned in — the
+ // screen never has to fire a follow-up call per voucher.
+ final String verifyJoin = """
+ LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
+ LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
+ va ON va.voucher_no = r.voucher_no
+ """;
+ final String verifyCols = """
+ vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
+ vv.verified_by_name, vv.verified_at,
+ vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
+ COALESCE(va.n, 0) AS attachment_count
+ """;
+
+ // Cashier deposits — money in, so the credit side.
+ rows.addAll(jdbcTemplate.query("""
+ SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
+ r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
+ r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
+ r.submitted_at, r.actioned_at,
+ """ + verifyCols + """
+ FROM m_cashier_deposit_request r
+ """ + verifyJoin + """
+ WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
+ """, this::mapDayBookRow, txnDate));
+
+ // Clerk withdrawals — money out, so the debit side.
+ rows.addAll(jdbcTemplate.query("""
+ SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
+ r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
+ r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id,
+ r.submitted_at, r.actioned_at,
+ """ + verifyCols + """
+ FROM m_clerk_withdrawal_request r
+ """ + verifyJoin + """
+ WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
+ """, this::mapDayBookRow, txnDate));
+
+ // Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
+ // bank transfer moves it out (DR) — matching how the voucher itself was numbered.
+ rows.addAll(jdbcTemplate.query("""
+ SELECT r.voucher_no,
+ CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
+ r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
+ r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
+ r.description, r.status, r.clerk_name AS handled_by,
+ r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
+ """ + verifyCols + """
+ FROM m_clerk_disbursement_request r
+ """ + verifyJoin + """
+ WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
+ """, this::mapDayBookRow, txnDate));
+
+ // Filtering and ordering happen here rather than in SQL: three separate queries cannot be
+ // ordered as one result set, and the row count for a single day is small.
+ final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
+ final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
+
+ final List> filtered = new ArrayList<>();
+ BigDecimal creditTotal = BigDecimal.ZERO;
+ BigDecimal debitTotal = BigDecimal.ZERO;
+
+ for (final Map row : rows) {
+ if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
+ continue;
+ }
+ if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
+ continue;
+ }
+ filtered.add(row);
+
+ // Only approved money is real money: a pending or rejected voucher must not inflate the
+ // day's totals, even though it still belongs in the listing.
+ if ("APPROVED".equalsIgnoreCase((String) row.get("status")) || "COMPLETED".equalsIgnoreCase((String) row.get("status"))) {
+ final BigDecimal amount = (BigDecimal) row.get("amount");
+ if (amount != null) {
+ if ("CR".equals(row.get("side"))) {
+ creditTotal = creditTotal.add(amount);
+ } else {
+ debitTotal = debitTotal.add(amount);
+ }
+ }
+ }
+ }
+
+ filtered.sort((a, b) -> {
+ final Object left = b.get("submittedAt");
+ final Object right = a.get("submittedAt");
+ if (left == null && right == null) {
+ return 0;
+ }
+ if (left == null) {
+ return -1;
+ }
+ if (right == null) {
+ return 1;
+ }
+ return left.toString().compareTo(right.toString());
+ });
+
+ final Map result = new LinkedHashMap<>();
+ result.put("date", txnDate);
+ result.put("rows", filtered);
+ result.put("count", filtered.size());
+ result.put("creditTotal", creditTotal);
+ result.put("debitTotal", debitTotal);
+ result.put("netTotal", creditTotal.subtract(debitTotal));
+ return gson.toJson(result);
+ }
+
+ private Map mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
+ final Map row = new LinkedHashMap<>();
+ row.put("voucherNo", rs.getString("voucher_no"));
+ row.put("side", rs.getString("side"));
+ row.put("kind", rs.getString("kind"));
+ row.put("accountNo", rs.getString("account_no"));
+ row.put("accountType", rs.getString("account_type"));
+ row.put("party", rs.getString("party"));
+ row.put("amount", rs.getBigDecimal("amount"));
+ row.put("paymentTypeName", rs.getString("payment_type_name"));
+ row.put("description", rs.getString("description"));
+ row.put("status", rs.getString("status"));
+ row.put("handledBy", rs.getString("handled_by"));
+ row.put("approvedBy", rs.getString("approved_by"));
+ row.put("txnId", rs.getString("txn_id"));
+
+ // Physical-slip check. verifyStatus is null until someone actually looks at the paper.
+ row.put("verifyStatus", rs.getString("verify_status"));
+ row.put("slipAmount", rs.getBigDecimal("slip_amount"));
+ row.put("verifyRemarks", rs.getString("verify_remarks"));
+ row.put("verifiedByName", rs.getString("verified_by_name"));
+ final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
+ row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
+
+ // How the exception ended, if it has. Without this the day book keeps showing a closed
+ // exception as though it were still open — the flag never clears on screen.
+ row.put("resolutionStatus", rs.getString("resolution_status"));
+ row.put("resolutionNotes", rs.getString("resolution_notes"));
+ row.put("resolvedByName", rs.getString("resolved_by_name"));
+ final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
+ row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
+ row.put("attachmentCount", rs.getInt("attachment_count"));
+ // Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
+ // form the browser cannot parse reliably.
+ final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
+ final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
+ row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
+ row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
+ return row;
+ }
+
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java
new file mode 100644
index 000000000..4db4f4227
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java
@@ -0,0 +1,754 @@
+package org.apache.fineract.portfolio.cashtransfer.api;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+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 jakarta.ws.rs.core.Response;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeParseException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.commands.domain.CommandWrapper;
+import org.apache.fineract.commands.service.CommandWrapperBuilder;
+import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.dao.IncorrectResultSizeDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Share Advance Collection API — cash taken from a member BEFORE any share account exists.
+ *
+ * The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
+ * flow.
+ *
+ *
+ * 1. Collect — {@code POST /v1/shareadvancecollection}
+ *
+ *
+ * Dr Cash at Vault (SHARES_REFERENCE) = amount
+ * Cr Share Sundry (SHARES_SUSPENSE) = amount
+ *
+ *
+ * The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
+ * {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
+ *
+ *
+ * 2. Apply — {@code POST /v1/shareadvancecollection/{id}/apply}
+ *
+ *
+ * Dr Share Sundry (SHARES_SUSPENSE) = everything collected
+ * Cr Share Capital (SHARES_EQUITY) = share amount
+ * Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
+ * Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
+ *
+ *
+ * Applying clears the liability, issues the share capital, marks the account's charges paid and sets
+ * {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
+ * Account creation, approval and activation post no GL of their own — all share accounting happens here.
+ *
+ *
+ * When the cash does not match
+ *
+ *
+ * A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
+ * argued with at the window:
+ *
+ *
+ * Short — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
+ * and applies the receipts together: one settlement, several receipts, all marked applied.
+ * Over — allowed, and the difference is credited to the member's linked savings account through Fineract's
+ * own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
+ * back to them.
+ *
+ */
+@Path("/v1/shareadvancecollection")
+@Component
+@RequiredArgsConstructor
+@Consumes({ MediaType.APPLICATION_JSON })
+@Produces({ MediaType.APPLICATION_JSON })
+public class ShareAdvanceCollectionApiResource {
+
+ // ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
+ // Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
+ private static final int DEBIT = 2;
+ private static final int CREDIT = 1;
+
+ // ── CashAccountsForShares financial_account_type ───────────────────────
+ private static final int FA_SHARES_REFERENCE = 1;
+ private static final int FA_SHARES_SUSPENSE = 2;
+ private static final int FA_INCOME_FROM_FEES = 3;
+ private static final int FA_SHARES_EQUITY = 4;
+
+ // ── acc_product_mapping.product_type for shares ────────────────────────
+ private static final int PRODUCT_TYPE_SHARES = 4;
+
+ // ── m_share_account.status_enum ───────────────────────────────────────
+ private static final int STATUS_ACTIVE = 300;
+
+ // ── GL account the cashier deposits share money into ──────────────────
+ private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001";
+
+ private final PlatformSecurityContext context;
+ private final JdbcTemplate jdbcTemplate;
+ private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
+ private final Gson gson = new Gson();
+
+ // ══════════════════════════════════════════════════════════════════════
+ // POST — Take cash from a member with no share account yet
+ // ══════════════════════════════════════════════════════════════════════
+ @POST
+ @Transactional
+ public Response collect(final String body) {
+
+ final AppUser currentUser = this.context.authenticatedUser();
+ currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
+
+ final JsonObject json = parseBody(body);
+ final Long clientId = getLong(json, "clientId");
+ final BigDecimal amount = getBigDecimal(json, "amount");
+ final String note = sanitiseNote(getString(json, "note"));
+ final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
+
+ if (clientId == null) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
+ }
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
+ }
+
+ final Map client = loadClient(clientId);
+ final Long officeId = toLong(client.get("office_id"));
+ final String clientName = (String) client.get("display_name");
+
+ // The share product drives the GL mapping. Collection happens before an account exists,
+ // so the product is supplied by the caller or defaults to the only mapped share product.
+ final Long productId = resolveProductId(getLong(json, "productId"));
+ final String currencyCode = resolveCurrency(productId);
+
+ final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
+ final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
+
+ final long userId = currentUser.getId();
+ final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final String txnId = "SHADV-" + System.nanoTime();
+ final String receiptNo = nextReceiptNo();
+
+ final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
+
+ insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
+ insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
+ "Share advance held in Sundry | Receipt " + receiptNo);
+
+ jdbcTemplate.update(
+ "INSERT INTO m_share_advance_collection "
+ + "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
+ receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
+ currentUser.getUsername(), note);
+
+ final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
+ receiptNo);
+
+ final Map out = new LinkedHashMap<>();
+ out.put("collectionId", id);
+ out.put("receiptNo", receiptNo);
+ out.put("clientId", clientId);
+ out.put("clientName", clientName);
+ out.put("amount", amount);
+ out.put("currency", currencyCode);
+ out.put("status", "UNAPPLIED");
+ out.put("glTransactionId", txnId);
+ out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // GET — Unapplied receipts, optionally for one client
+ // ══════════════════════════════════════════════════════════════════════
+ @GET
+ public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
+ this.context.authenticatedUser();
+
+ final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ + " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
+ + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
+ if (clientId != null) {
+ sql.append(" AND c.client_id = ").append(clientId);
+ }
+ sql.append(" ORDER BY c.collection_date, c.id");
+
+ final List> rows = jdbcTemplate.queryForList(sql.toString());
+ final Map out = new LinkedHashMap<>();
+ out.put("unappliedCollections", rows);
+ out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // GET — One unapplied receipt by the voucher number quoted by the member
+ // ══════════════════════════════════════════════════════════════════════
+ // The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001:
+ // as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
+ // firewall with a 400 before the request reaches this resource. The older
+ // /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
+ @GET
+ @Path("/by-voucher")
+ public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
+ @QueryParam("clientId") final Long clientIdHint) {
+ if (voucherNo == null || voucherNo.isBlank()) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
+ "A voucher or receipt number is required.");
+ }
+ return findByVoucher(voucherNo, clientIdHint);
+ }
+
+ @GET
+ @Path("/by-voucher/{voucherNo}")
+ public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) {
+ this.context.authenticatedUser();
+
+ // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
+ final List> rows = jdbcTemplate
+ .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ + " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
+ + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
+ + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
+
+ if (rows.isEmpty()) {
+ // Cash is normally taken through the cashier's Transaction Entry deposit into the Share
+ // Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a
+ // collection so the member's voucher resolves here.
+ final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint);
+ if (adopted != null) {
+ return Response.ok(gson.toJson(adopted)).build();
+ }
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
+ + voucherNo + ". Check the number, or confirm the deposit has been approved by a manager.");
+ }
+
+ final Map row = rows.get(0);
+ if (!"UNAPPLIED".equals(row.get("status"))) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
+ "Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
+ }
+ return Response.ok(gson.toJson(row)).build();
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // POST — Apply a receipt to an activated share account
+ // ══════════════════════════════════════════════════════════════════════
+ @POST
+ @Path("/{collectionId}/apply")
+ @Transactional
+ public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
+
+ final AppUser currentUser = this.context.authenticatedUser();
+ currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
+
+ final JsonObject json = parseBody(body);
+ final Long shareAccountId = getLong(json, "shareAccountId");
+ if (shareAccountId == null) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
+ }
+
+ // A member who pays in instalments has more than one receipt against the same application:
+ // ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
+ // are applied together as one settlement. The path receipt leads; any others come in the
+ // body.
+ final List collectionIds = new java.util.ArrayList<>();
+ collectionIds.add(collectionId);
+ for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
+ if (!collectionIds.contains(additionalId)) {
+ collectionIds.add(additionalId);
+ }
+ }
+
+ final List> collections = new java.util.ArrayList<>();
+ for (final Long id : collectionIds) {
+ final Map row = loadCollection(id);
+ if (!"UNAPPLIED".equals(row.get("status"))) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
+ "Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
+ }
+ collections.add(row);
+ }
+
+ final Map coll = collections.get(0);
+
+ final Map acct = loadAccount(shareAccountId);
+
+ // Every receipt belongs to the member: applying one to someone else's account would move
+ // one member's money into another's share capital. Checked per receipt, since a settlement
+ // can now carry several.
+ final Long acctClientId = toLong(acct.get("client_id"));
+ for (final Map row : collections) {
+ if (!toLong(row.get("client_id")).equals(acctClientId)) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
+ + " belongs to a different member than share account " + acct.get("account_no") + ".");
+ }
+ }
+
+ final int statusEnum = toInt(acct.get("status_enum"));
+ if (statusEnum != STATUS_ACTIVE) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
+ "Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
+ }
+ if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
+ "Share account " + acct.get("account_no") + " has already been settled.");
+ }
+
+ // Share value the account was opened for, and any entry fee attached to it.
+ final BigDecimal shareAmount = shareValueOf(shareAccountId);
+ final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
+ final BigDecimal required = shareAmount.add(chargeAmount);
+
+ BigDecimal receiptAmount = BigDecimal.ZERO;
+ final StringBuilder receiptNos = new StringBuilder();
+ for (final Map row : collections) {
+ receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
+ if (receiptNos.length() > 0) {
+ receiptNos.append(" + ");
+ }
+ receiptNos.append(row.get("receipt_no"));
+ }
+
+ // Short is blocked: share capital cannot be part-issued — the member either bought those
+ // shares or did not — so settling for less would leave the account short by an amount owed
+ // by nobody. The clerk collects the balance on another receipt and applies both together.
+ if (receiptAmount.compareTo(required) < 0) {
+ final BigDecimal shortfall = required.subtract(receiptAmount);
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
+ "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
+ + required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall
+ + " from the member, then apply both receipts together.");
+ }
+
+ // Over-payment is allowed through rather than turning a member away over a rounded-up note.
+ // The difference is credited to their linked savings account below.
+ final BigDecimal excessAmount = receiptAmount.subtract(required);
+ final Long savingsAccountId = toLong(acct.get("savings_account_id"));
+ if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
+ "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
+ + " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings "
+ + "account, but none is linked to this share account. Link one, or collect the exact amount.");
+ }
+
+ final Long officeId = toLong(acct.get("office_id"));
+ final Long productId = toLong(acct.get("product_id"));
+ final String currencyCode = (String) acct.get("currency_code");
+
+ final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
+ final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
+
+ final long userId = currentUser.getId();
+ final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
+ final String txnId = "SHAPP-" + System.nanoTime();
+ final String accountNo = (String) acct.get("account_no");
+
+ // Dr Sundry for everything collected, including any over-payment — the liability those
+ // receipts created is cleared in full, in one leg, so nothing is left behind in suspense.
+ insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
+ "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
+
+ // Cr Share Capital for the share value.
+ insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
+ "Share capital issued for " + accountNo);
+
+ // Cr Income from Fees for the entry fee, when one was charged.
+ if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
+ // Resolved from the share product's "Income from Fees" mapping, which points at the
+ // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
+ final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
+ insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
+ "Share entry fee for " + accountNo);
+
+ jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
+ }
+
+ // The over-payment goes back to the member as a savings deposit rather than a bare journal
+ // entry, so it lands in their passbook and running balance the way any counter deposit
+ // would. Posted through Fineract's own deposit command, which writes its own Cr Savings
+ // Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
+ Long excessSavingsTransactionId = null;
+ if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
+ excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
+ "Excess on share application receipt " + receiptNos + " for share account " + accountNo);
+ }
+
+ // Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
+ for (final Map row : collections) {
+ if (row.get("voucher_no") != null) {
+ jdbcTemplate.update(
+ "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ + " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL",
+ shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO);
+ }
+ }
+
+ // Every receipt in this settlement is marked applied, not just the one in the path — they
+ // were all consumed by it, and an unmarked one would still show as money awaiting use.
+ for (final Long id : collectionIds) {
+ jdbcTemplate.update(
+ "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
+ shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
+ }
+
+ // Reveals the purchase transaction in the passbook and transactions views.
+ jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
+
+ final Map out = new LinkedHashMap<>();
+ out.put("collectionId", collectionId);
+ out.put("collectionIds", collectionIds);
+ out.put("receiptNo", receiptNos.toString());
+ out.put("shareAccountId", shareAccountId);
+ out.put("accountNo", accountNo);
+ out.put("shareAmount", shareAmount);
+ out.put("chargeAmount", chargeAmount);
+ out.put("totalApplied", required);
+ out.put("totalCollected", receiptAmount);
+ out.put("glTransactionId", txnId);
+ out.put("status", "APPLIED");
+ // Told at the counter, while the member is still there, rather than left to be noticed on
+ // a statement later.
+ out.put("excessAmount", excessAmount);
+ if (excessSavingsTransactionId != null) {
+ out.put("excessSavingsTransactionId", excessSavingsTransactionId);
+ out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
+ + "their savings account.");
+ }
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // Helpers
+ // ══════════════════════════════════════════════════════════════════════
+
+ /**
+ * Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
+ *
+ * The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
+ * so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
+ * so the voucher resolves and can be applied.
+ *
+ * A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
+ * clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
+ * is what keeps the money tied to the right member.
+ *
+ * @return the newly created collection row, or null when no approved Sundry deposit matches.
+ */
+ private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) {
+ final List> deposits = jdbcTemplate
+ .queryForList(
+ "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
+ + " FROM m_cashier_deposit_request d"
+ + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
+ voucherNo, SHARE_SUNDRY_ACCOUNT_NO);
+
+ if (deposits.isEmpty()) {
+ return null;
+ }
+ final Map dep = deposits.get(0);
+
+ if (toLong(dep.get("share_account_id")) != null) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
+ "Voucher " + voucherNo + " has already been applied to a share account.");
+ }
+
+ // Prefer the member recorded on the deposit; fall back to the account being settled.
+ final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
+ if (clientId == null) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
+ "Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
+ + " so it cannot be matched. Re-enter the deposit against the member.");
+ }
+
+ final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
+ final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
+ final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
+ : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
+
+ final String receiptNo = nextReceiptNo();
+ jdbcTemplate.update(
+ "INSERT INTO m_share_advance_collection "
+ + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
+ receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
+ dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
+ "Adopted from cashier deposit voucher " + voucherNo);
+
+ return jdbcTemplate.queryForMap(
+ "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
+ receiptNo);
+ }
+
+ private Long loadClientOfficeId(final Long clientId) {
+ return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
+ }
+
+ /** Share value the account was opened for: the pending/approved purchase transaction. */
+ private BigDecimal shareValueOf(final Long shareAccountId) {
+ final BigDecimal total = jdbcTemplate
+ .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
+ return total == null ? BigDecimal.ZERO : total;
+ }
+
+ /** Entry fee still outstanding on the account, zero when no charge was attached. */
+ private BigDecimal outstandingChargeOf(final Long shareAccountId) {
+ // Driven by the money still owed rather than the is_paid_derived flag alone: accounts
+ // created before the flat-charge fix carry the flag set to true while the full amount is
+ // still outstanding, and those charges must still be settled by the collection.
+ final BigDecimal total = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
+ + " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
+ BigDecimal.class, shareAccountId);
+ return total == null ? BigDecimal.ZERO : total;
+ }
+
+ private Map loadCollection(final Long id) {
+ try {
+ return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
+ } catch (EmptyResultDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
+ "Share advance collection " + id + " does not exist.");
+ }
+ }
+
+ private Map loadClient(final Long clientId) {
+ try {
+ return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
+ } catch (EmptyResultDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
+ }
+ }
+
+ private Map loadAccount(final Long shareAccountId) {
+ try {
+ return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
+ + " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
+ + " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
+ } catch (EmptyResultDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
+ "Share account " + shareAccountId + " does not exist.");
+ }
+ }
+
+ /**
+ * Uses the caller's product when given, else the single mapped share product.
+ *
+ * The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
+ * not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
+ * when only one real share product is mapped.
+ */
+ private Long resolveProductId(final Long requested) {
+ if (requested != null) {
+ return requested;
+ }
+ final List ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
+ + " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
+ if (ids.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
+ "No share product has GL mappings configured.");
+ }
+ if (ids.size() > 1) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
+ "productId is required: " + ids.size() + " share products have GL mappings.");
+ }
+ return ids.get(0);
+ }
+
+ private String resolveCurrency(final Long productId) {
+ try {
+ final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
+ productId);
+ return code == null ? "INR" : code;
+ } catch (EmptyResultDataAccessException e) {
+ return "INR";
+ }
+ }
+
+ private Long requireGl(final Long productId, final int financialAccountType, final String label) {
+ final Long glId = resolveGl(productId, financialAccountType);
+ if (glId == null) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
+ label + " GL is not mapped for share product " + productId + ".");
+ }
+ return glId;
+ }
+
+ private Long resolveGl(final Long productId, final int financialAccountType) {
+ try {
+ return jdbcTemplate.queryForObject(
+ "SELECT gl_account_id FROM acc_product_mapping "
+ + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
+ Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
+ } catch (IncorrectResultSizeDataAccessException e) {
+ return null;
+ }
+ }
+
+ private String nextReceiptNo() {
+ final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
+ return String.format("SHADV%06d", seq == null ? 1 : seq);
+ }
+
+ private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
+ final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
+ final String description) {
+ jdbcTemplate.update(
+ "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ + " entry_date, type_enum, amount, description, entity_id, "
+ + " created_by, last_modified_by, created_date, lastmodified_date, "
+ + " is_running_balance_calculated, office_running_balance, "
+ + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
+ glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
+ userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
+ }
+
+ /**
+ * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
+ *
+ * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
+ * 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
+ * the tenant's own zone first, which is the zone {@code now} was taken in.
+ */
+ private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
+ return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
+ .withOffsetSameInstant(ZoneOffset.UTC);
+ }
+
+ private JsonObject parseBody(final String body) {
+ if (body == null || body.isBlank()) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
+ }
+ try {
+ return JsonParser.parseString(body).getAsJsonObject();
+ } catch (Exception e) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
+ }
+ }
+
+ private LocalDate parseDate(final String raw) {
+ if (raw == null || raw.isBlank()) {
+ return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
+ }
+ try {
+ return LocalDate.parse(raw);
+ } catch (DateTimeParseException e) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
+ "transactionDate must be in YYYY-MM-DD format. Received: " + raw);
+ }
+ }
+
+ private String sanitiseNote(final String raw) {
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ final String trimmed = raw.trim();
+ return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
+ }
+
+ private String getString(final JsonObject json, final String field) {
+ return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
+ }
+
+ private Long getLong(final JsonObject json, final String field) {
+ return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
+ }
+
+ private BigDecimal getBigDecimal(final JsonObject json, final String field) {
+ return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
+ }
+
+ /** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
+ private List getLongList(final JsonObject json, final String field) {
+ final List values = new java.util.ArrayList<>();
+ if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
+ return values;
+ }
+ for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
+ if (!element.isJsonNull()) {
+ values.add(element.getAsLong());
+ }
+ }
+ return values;
+ }
+
+ /**
+ * Credits an over-payment to the member's savings account.
+ *
+ * Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
+ * the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
+ * would move the balance in the ledger while leaving the member's own account history silent about it.
+ */
+ private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
+ final JsonObject payload = new JsonObject();
+ payload.addProperty("locale", "en");
+ payload.addProperty("dateFormat", "yyyy-MM-dd");
+ payload.addProperty("transactionDate", txnDate.toString());
+ payload.addProperty("transactionAmount", amount);
+ payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId());
+ payload.addProperty("note", note);
+
+ final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
+ .build();
+ return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
+ }
+
+ /**
+ * The payment type this deposit is recorded under.
+ *
+ * Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
+ * the opposite direction — an internal transfer between a share application and the member's savings, not cash
+ * over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
+ */
+ private Long resolveExcessPaymentTypeId() {
+ final List byName = jdbcTemplate.queryForList(
+ "SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
+ if (!byName.isEmpty()) {
+ return byName.get(0);
+ }
+ final List any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
+ if (any.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
+ "No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
+ }
+ return any.get(0);
+ }
+
+ private Long toLong(final Object o) {
+ return o instanceof Number ? ((Number) o).longValue() : null;
+ }
+
+ private int toInt(final Object o) {
+ return o instanceof Number ? ((Number) o).intValue() : 0;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java
index 5e7d9c977..bca22f22d 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java
@@ -375,8 +375,20 @@ public class ShareCollectionApiResource {
userId, userId, now, now,
java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate),
- now.atOffset(ZoneOffset.UTC),
- now.atOffset(ZoneOffset.UTC));
+ toUtc(now),
+ toUtc(now));
+ }
+
+ /**
+ * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
+ *
+ * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
+ * 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
+ * the tenant's own zone first, which is the zone {@code now} was taken in.
+ */
+ private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
+ return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
+ .withOffsetSameInstant(ZoneOffset.UTC);
}
private Map glEntryMap(final String account, final String type,
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java
new file mode 100644
index 000000000..f80c7fc50
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java
@@ -0,0 +1,601 @@
+/**
+ * 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.cashtransfer.api;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DefaultValue;
+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 jakarta.ws.rs.core.Response;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.commands.domain.CommandWrapper;
+import org.apache.fineract.commands.service.CommandWrapperBuilder;
+import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.dao.IncorrectResultSizeDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Settles a share application out of the member's own savings account.
+ *
+ * The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks
+ * in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks
+ * with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly:
+ *
+ *
+ * Dr Savings Control (share value + entry fee)
+ * Cr Share Capital (share value)
+ * Cr Entry Fee Income (charge, when one is attached)
+ *
+ *
+ * No Sundry leg, because nothing is ever held — the debit and the share issue happen together.
+ *
+ * Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval
+ * touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair
+ * of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves.
+ *
+ * The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and
+ * running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own
+ * Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and
+ * replaced with the share-capital pair above.
+ */
+@Path("/v1/sharesavingsdebit")
+@Component
+@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.")
+@RequiredArgsConstructor
+@Consumes({ MediaType.APPLICATION_JSON })
+@Produces({ MediaType.APPLICATION_JSON })
+public class ShareSavingsDebitRequestApiResource {
+
+ // Fineract convention: 1 = CREDIT, 2 = DEBIT.
+ private static final int CREDIT = 1;
+ private static final int DEBIT = 2;
+
+ // acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource.
+ private static final int FA_INCOME_FROM_FEES = 3;
+ private static final int FA_SHARES_EQUITY = 4;
+ private static final int PRODUCT_TYPE_SHARES = 4;
+
+ /** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */
+ private static final int PRODUCT_TYPE_SAVINGS = 2;
+ private static final int FA_SAVINGS_CONTROL = 2;
+
+ private static final int SHARE_STATUS_ACTIVE = 300;
+ private static final int SAVINGS_STATUS_ACTIVE = 300;
+
+ /** m_payment_type.value seeded by changelog 0483 for this settlement. */
+ private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
+
+ private final PlatformSecurityContext context;
+ private final JdbcTemplate jdbcTemplate;
+ private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
+ private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
+
+ // ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ──────────────
+
+ /**
+ * Whether this share account can be settled from savings, and for how much.
+ *
+ * Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked,
+ * does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the
+ * counter instead of after a request has been raised and has to be rejected.
+ */
+ @GET
+ @Path("/eligibility/{shareAccountId}")
+ public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) {
+ this.context.authenticatedUser();
+ final Map acct = loadShareAccount(shareAccountId);
+
+ final BigDecimal shareAmount = shareValueOf(shareAccountId);
+ final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
+ final BigDecimal required = shareAmount.add(chargeAmount);
+
+ final Map out = new LinkedHashMap<>();
+ out.put("shareAccountId", shareAccountId);
+ out.put("shareAccountNo", acct.get("account_no"));
+ out.put("shareAmount", shareAmount);
+ out.put("chargeAmount", chargeAmount);
+ out.put("totalRequired", required);
+
+ final Object savingsAccountIdRaw = acct.get("savings_account_id");
+ if (savingsAccountIdRaw == null) {
+ out.put("eligible", false);
+ out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead.");
+ return Response.ok(gson.toJson(out)).build();
+ }
+ final Long savingsAccountId = toLong(savingsAccountIdRaw);
+ final Map savings = loadSavingsAccount(savingsAccountId);
+ final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
+ final BigDecimal available = balance == null ? BigDecimal.ZERO : balance;
+
+ out.put("savingsAccountId", savingsAccountId);
+ out.put("savingsAccountNo", savings.get("account_no"));
+ out.put("savingsBalance", available);
+
+ final String blocker = settlementBlocker(acct, savings, required, available);
+ out.put("eligible", blocker == null);
+ out.put("reason", blocker);
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ /**
+ * Why this share account cannot be settled from savings right now, or null when it can.
+ *
+ * Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement
+ * is allowed.
+ */
+ private String settlementBlocker(final Map shareAccount, final Map savings,
+ final BigDecimal required, final BigDecimal available) {
+
+ if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) {
+ return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled.";
+ }
+ if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) {
+ return "Share account " + shareAccount.get("account_no") + " has already been settled.";
+ }
+ if (required.compareTo(BigDecimal.ZERO) <= 0) {
+ return "There is nothing outstanding on share account " + shareAccount.get("account_no") + ".";
+ }
+ // The savings account must belong to the same member — debiting anyone else's balance for
+ // these shares would be taking one member's money for another's holding.
+ if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) {
+ return "The linked savings account belongs to a different member.";
+ }
+ if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
+ return "Savings account " + savings.get("account_no") + " is not Active.";
+ }
+ if (available.compareTo(required) < 0) {
+ return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required
+ + " is required. Take a deposit first, or settle by cash collection.";
+ }
+ return null;
+ }
+
+ // ── POST /v1/sharesavingsdebit ── Clerk records the request ─────────────
+
+ @POST
+ @Transactional
+ public Response submit(final String body) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ final JsonObject json = parseBody(body);
+ final Long shareAccountId = getLong(json, "shareAccountId");
+ if (shareAccountId == null) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required.");
+ }
+
+ final Map acct = loadShareAccount(shareAccountId);
+ final Object savingsAccountIdRaw = acct.get("savings_account_id");
+ if (savingsAccountIdRaw == null) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings",
+ "Share account " + acct.get("account_no") + " has no linked savings account.");
+ }
+ final Long savingsAccountId = toLong(savingsAccountIdRaw);
+ final Map savings = loadSavingsAccount(savingsAccountId);
+
+ final BigDecimal shareAmount = shareValueOf(shareAccountId);
+ final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
+ final BigDecimal required = shareAmount.add(chargeAmount);
+ final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
+
+ // Checked at submit as well as at approval: the clerk should be told now, not after the
+ // member has left and a manager finds the account short.
+ final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
+ if (blocker != null) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
+ }
+
+ final Long existingPending = this.jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class,
+ shareAccountId);
+ if (existingPending != null && existingPending > 0) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending",
+ "A savings-debit request for this share account is already pending review.");
+ }
+
+ final Long newId = this.jdbcTemplate.queryForObject("""
+ INSERT INTO m_share_savings_debit_request
+ (share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount,
+ currency_code, status, clerk_id, clerk_name, note)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
+ RETURNING id
+ """, Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required,
+ acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note"));
+
+ final Map out = new LinkedHashMap<>();
+ out.put("resourceId", newId);
+ out.put("status", "PENDING");
+ out.put("totalAmount", required);
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ── GET /v1/sharesavingsdebit ── Requests awaiting review ───────────────
+
+ @GET
+ public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status,
+ @QueryParam("shareAccountId") final Long shareAccountId) {
+ this.context.authenticatedUser();
+
+ final StringBuilder sql = new StringBuilder("""
+ SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name,
+ r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance,
+ r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status,
+ r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at,
+ r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note
+ FROM m_share_savings_debit_request r
+ JOIN m_share_account sa ON sa.id = r.share_account_id
+ JOIN m_client c ON c.id = r.client_id
+ LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
+ WHERE 1 = 1
+ """);
+ final List params = new ArrayList<>();
+ if (!"ALL".equalsIgnoreCase(status)) {
+ sql.append(" AND r.status = ? ");
+ params.add(status.toUpperCase());
+ }
+ if (shareAccountId != null) {
+ sql.append(" AND r.share_account_id = ? ");
+ params.add(shareAccountId);
+ }
+ sql.append(" ORDER BY r.submitted_at DESC, r.id DESC");
+
+ return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build();
+ }
+
+ // ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ─────────
+
+ /**
+ * Debits the member's savings and issues the shares.
+ *
+ * Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the
+ * approval is the point at which it actually matters.
+ */
+ @POST
+ @Path("/{id}/approve")
+ @Transactional
+ public Response approve(@PathParam("id") final Long id, final String body) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
+ validateCheckerRole(currentUser);
+
+ final Map req = loadRequest(id);
+ if (!"PENDING".equals(req.get("status"))) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
+ "Only PENDING requests can be approved. This one is " + req.get("status") + ".");
+ }
+ if (toLong(req.get("clerk_id")).equals(currentUser.getId())) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval",
+ "Maker-checker: you cannot approve a request you submitted yourself. Ask another manager.");
+ }
+
+ final Long shareAccountId = toLong(req.get("share_account_id"));
+ final Long savingsAccountId = toLong(req.get("savings_account_id"));
+ final Map acct = loadShareAccount(shareAccountId);
+ final Map savings = loadSavingsAccount(savingsAccountId);
+
+ // Recomputed rather than taken from the request: shares or charges may have been edited
+ // between submit and approval, and the member must be debited for what is actually owed.
+ final BigDecimal shareAmount = shareValueOf(shareAccountId);
+ final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
+ final BigDecimal required = shareAmount.add(chargeAmount);
+ final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
+
+ final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
+ if (blocker != null) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
+ }
+
+ final Long productId = toLong(acct.get("product_id"));
+ final Long officeId = toLong(acct.get("office_id"));
+ final String currencyCode = (String) acct.get("currency_code");
+ final String accountNo = (String) acct.get("account_no");
+
+ final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
+ final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
+ if (glSavingsControl == null) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped",
+ "The Savings Control GL is not mapped for this savings account's product.");
+ }
+
+ final LocalDate entryDate = DateUtils.getBusinessLocalDate();
+ final String note = "Share application settled from savings | " + accountNo;
+
+ // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
+ // pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money
+ // became share capital, which the pair below records.
+ final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note);
+ this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
+
+ final long userId = currentUser.getId();
+ final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final String txnId = "SHSAV-" + savingsTxnId;
+
+ insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode,
+ "Share application debited from savings " + savings.get("account_no") + " | " + accountNo);
+ insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
+ "Share capital issued for " + accountNo);
+
+ if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
+ final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
+ insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
+ "Share entry fee for " + accountNo);
+ this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
+ }
+
+ // Marks the account settled, which is what stops it being settled again by either route.
+ this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
+
+ this.jdbcTemplate.update("""
+ UPDATE m_share_savings_debit_request
+ SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(),
+ share_amount = ?, charge_amount = ?, total_amount = ?,
+ savings_transaction_id = ?, gl_transaction_id = ?
+ WHERE id = ? AND status = 'PENDING'
+ """, currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id);
+
+ final Map out = new LinkedHashMap<>();
+ out.put("resourceId", id);
+ out.put("status", "APPROVED");
+ out.put("shareAmount", shareAmount);
+ out.put("chargeAmount", chargeAmount);
+ out.put("totalDebited", required);
+ out.put("savingsTransactionId", savingsTxnId);
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ───────────
+
+ @POST
+ @Path("/{id}/reject")
+ @Transactional
+ public Response reject(@PathParam("id") final Long id, final String body) {
+ final AppUser currentUser = this.context.authenticatedUser();
+ validateCheckerRole(currentUser);
+
+ final Map req = loadRequest(id);
+ if (!"PENDING".equals(req.get("status"))) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
+ "Only PENDING requests can be rejected. This one is " + req.get("status") + ".");
+ }
+
+ final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body);
+ this.jdbcTemplate.update("""
+ UPDATE m_share_savings_debit_request
+ SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ?
+ WHERE id = ? AND status = 'PENDING'
+ """, currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id);
+
+ final Map out = new LinkedHashMap<>();
+ out.put("resourceId", id);
+ out.put("status", "REJECTED");
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────
+
+ private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
+ final String note) {
+ final JsonObject payload = new JsonObject();
+ payload.addProperty("locale", "en");
+ payload.addProperty("dateFormat", "yyyy-MM-dd");
+ payload.addProperty("transactionDate", txnDate.toString());
+ payload.addProperty("transactionAmount", amount);
+ payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
+ payload.addProperty("note", note);
+
+ final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
+ .build();
+ final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
+ return result.getResourceId();
+ }
+
+ /**
+ * The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the
+ * withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out.
+ * The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it
+ * on the member's statement and in payment-type reporting.
+ *
+ * Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles
+ * rather than failing at approval with money already half-moved.
+ */
+ private Long resolveSettlementPaymentTypeId() {
+ final List> rows = this.jdbcTemplate
+ .queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
+ if (!rows.isEmpty()) {
+ return toLong(rows.get(0).get("id"));
+ }
+ final List> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
+ if (fallback.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type",
+ "No payment types are configured. Add one before settling a share application from savings.");
+ }
+ return toLong(fallback.get(0).get("id"));
+ }
+
+ /** Savings Control GL for this savings account's product. */
+ private Long savingsControlGlOf(final Long savingsAccountId) {
+ final List> rows = this.jdbcTemplate.queryForList("""
+ SELECT apm.gl_account_id FROM m_savings_account sa
+ JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
+ WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
+ """, savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
+ return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
+ }
+
+ /** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */
+ private BigDecimal shareValueOf(final Long shareAccountId) {
+ final BigDecimal total = this.jdbcTemplate
+ .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
+ return total == null ? BigDecimal.ZERO : total;
+ }
+
+ /** Entry fee still outstanding, zero when no charge was attached. */
+ private BigDecimal outstandingChargeOf(final Long shareAccountId) {
+ final BigDecimal total = this.jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge "
+ + " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0",
+ BigDecimal.class, shareAccountId);
+ return total == null ? BigDecimal.ZERO : total;
+ }
+
+ private Map loadShareAccount(final Long shareAccountId) {
+ try {
+ return this.jdbcTemplate.queryForMap("""
+ SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
+ sa.collection_applied, sa.savings_account_id, c.office_id
+ FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?
+ """, shareAccountId);
+ } catch (final EmptyResultDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found",
+ "Share account " + shareAccountId + " does not exist.");
+ }
+ }
+
+ private Map loadSavingsAccount(final Long savingsAccountId) {
+ try {
+ return this.jdbcTemplate.queryForMap(
+ "SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?",
+ savingsAccountId);
+ } catch (final EmptyResultDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found",
+ "Savings account " + savingsAccountId + " does not exist.");
+ }
+ }
+
+ private Map loadRequest(final Long id) {
+ try {
+ return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id);
+ } catch (final EmptyResultDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found",
+ "Savings-debit request " + id + " does not exist.");
+ }
+ }
+
+ private Long requireGl(final Long productId, final int financialAccountType, final String label) {
+ try {
+ final Long glId = this.jdbcTemplate.queryForObject(
+ "SELECT gl_account_id FROM acc_product_mapping "
+ + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
+ Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
+ if (glId == null) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
+ label + " GL is not mapped for share product " + productId + ".");
+ }
+ return glId;
+ } catch (final IncorrectResultSizeDataAccessException e) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
+ label + " GL is not mapped for share product " + productId + ".");
+ }
+ }
+
+ private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
+ final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
+ final String description) {
+ this.jdbcTemplate.update(
+ "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ + " entry_date, type_enum, amount, description, entity_id, "
+ + " created_by, last_modified_by, created_date, lastmodified_date, "
+ + " is_running_balance_calculated, office_running_balance, "
+ + " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) "
+ + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
+ glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
+ userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
+ }
+
+ /**
+ * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
+ *
+ * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
+ * 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's
+ * own zone first, which is the zone {@code now} was taken in.
+ */
+ private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
+ return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
+ .withOffsetSameInstant(ZoneOffset.UTC);
+ }
+
+ private void validateCheckerRole(final AppUser user) {
+ if (user.hasAnyPermission("ALL_FUNCTIONS")) {
+ return;
+ }
+ final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
+ .anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
+ if (!isChecker) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required",
+ "Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request.");
+ }
+ }
+
+ private JsonObject parseBody(final String body) {
+ if (body == null || body.isBlank()) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required.");
+ }
+ try {
+ return JsonParser.parseString(body).getAsJsonObject();
+ } catch (final RuntimeException e) {
+ throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON.");
+ }
+ }
+
+ private Long getLong(final JsonObject json, final String field) {
+ return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
+ }
+
+ private String getString(final JsonObject json, final String field) {
+ if (!json.has(field) || json.get(field).isJsonNull()) {
+ return null;
+ }
+ final String value = json.get(field).getAsString().trim();
+ return value.isEmpty() ? null : value;
+ }
+
+ private Long toLong(final Object o) {
+ return o instanceof Number n ? n.longValue() : null;
+ }
+
+ private int toInt(final Object o) {
+ return o instanceof Number n ? n.intValue() : 0;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java
index bbea5abf1..53913d2da 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java
@@ -185,9 +185,13 @@ public class ClerkDisbursementRequestApiResource {
activityLog, Timestamp.from(Instant.now()), requestType
);
+ // A collection pulls money into the GL, so it reads as a credit; a disbursement and a
+ // bank transfer both move money out of the source side, so they read as a debit.
+ final String voucherPrefix = "COLLECTION".equals(requestType)
+ ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
final String voucherNo = voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
- newId, txnAmount, currentUser);
+ newId, txnAmount, currentUser, voucherPrefix);
jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
@@ -544,7 +548,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
- final String btVoucherNo = generateBtVoucherNo(btLogId);
+ final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
@@ -981,7 +985,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, logKh);
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
- final String voucherNo = generateBtVoucherNo(logId);
+ final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
final JsonObject res = new JsonObject();
@@ -997,9 +1001,15 @@ public class ClerkDisbursementRequestApiResource {
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
* under concurrent inserts on the same date.
*/
- private String generateBtVoucherNo(final long logId) {
- return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
- + String.format("%05d", logId);
+ /**
+ * Bank-transfer settlement voucher, drawn from the same central register as every other
+ * voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
+ * the debited accounts, so it reads as a debit: DR/001/2108/0001.
+ */
+ private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
+ return voucherService.createVoucher(
+ VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG,
+ logId, amount, currentUser, VoucherService.PREFIX_DEBIT);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java
index 8a51e1603..fc2bad879 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java
@@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
-import java.math.RoundingMode;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
@@ -58,36 +57,29 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
- * Maker-checker workflow for a member to claim an unclaimed dividend (see
- * {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
- * that was never credited into any savings account). A clerk (maker) records exactly how the
- * member wants it split when they visit the branch: straight into an overdue loan, credited to
- * their linked savings account, or handed over as cash -- any combination, any leftover amount
- * stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
- * the real disbursement, or rejects it, leaving the unclaimed liability untouched.
+ * Maker-checker workflow for a member to claim an unclaimed dividend (see {@code m_share_dividend_lock}, status
+ * {@code UNCLAIMED} -- a "Dividend Payable" GL liability that was never credited into any savings account). A clerk
+ * (maker) records the claim when the member visits the branch; a Bank Manager (checker) approves it, which draws the
+ * draft, or rejects it, leaving the unclaimed liability untouched. Any amount not claimed stays {@code UNCLAIMED} for a
+ * later visit, and is forfeited to profit if three years pass.
*
*
- * Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
- * repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
- * which debits whatever GL account is actually configured as that product's Fund Source -- not
- * necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
- * "Warehouse Receipt" account instead). Since the money never actually came from that account,
- * that same DEBIT leg is rewritten in place to Dividend Payable (see
- * {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
- * entry pair, so the transaction ends up with exactly the entries a real accountant would expect
- * -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched --
- * instead of that fund-source account confusingly appearing twice. The cash leg has no account to
- * post through, so approval
- * posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the
- * physical hand-over is deferred: the claim's {@code cash_status} becomes
- * {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be
- * redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before
- * the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s
- * submit/approve/disburse token flow for ordinary savings withdrawals.
+ * A dividend is collected as a Demand Draft, and only as a Demand Draft. The savings-credit, cash-at-counter and
+ * loan-set-off routes this workflow previously offered are retired: a draft leaves a numbered instrument the member
+ * holds and the branch can trace, where a savings credit disappeared into a running balance and cash left no
+ * counterfoil at all. Claims settled through the old routes remain readable — their columns are still populated on
+ * those historical rows — but new claims only ever write {@code dd_apply_amount}.
+ *
+ *
+ * The draft is raised through the ordinary Sponsor DD flow, consuming a leaflet from inventory and carrying a real DD
+ * number, so it reconciles, stops and reprints like any other. What differs is the funding: a DD bought at the counter
+ * debits the purchaser, whereas this one is funded from the society's own Dividend Payable liability, because the
+ * member is being paid rather than buying anything. That is what {@code payment_method = 'DIVIDEND'} marks on the issue
+ * request, and why no charge or GST attaches to it.
*/
@Path("/v1/clerk/dividend-claim-requests")
@Component
-@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
+@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.")
@RequiredArgsConstructor
public class DividendClaimRequestApiResource {
@@ -98,44 +90,27 @@ public class DividendClaimRequestApiResource {
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
- private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
+ /** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
+ private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
+ private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
+
+ /**
+ * Named as the purchaser on a dividend draft. The society bought the draft out of its own Dividend Payable, not the
+ * member — so the register shows who actually raised it rather than repeating the beneficiary's name in both
+ * columns, which would read as a counter sale to the member.
+ */
+ private static final String PURCHASER_SOCIETY_DIVIDEND = "THE SOCIETY (Dividend Payout)";
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
- "(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
- "r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
- "l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason",
- "r.submitted_at", "r.actioned_at" };
-
- // ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
-
- /** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
- @GET
- @Path("/overdue-loans/{clientId}")
- @Produces(MediaType.APPLICATION_JSON)
- public String overdueLoans(@PathParam("clientId") final Long clientId) {
- final List> loans = jdbcTemplate.queryForList("""
- SELECT ln.id AS loan_id, ln.account_no,
- SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- - COALESCE(ri.principal_writtenoff_derived, 0), 0)
- + GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- - COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
- FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
- WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
- GROUP BY ln.id, ln.account_no
- ORDER BY overdue_amount DESC
- """, clientId);
- final JsonArray result = new JsonArray();
- for (final Map loan : loans) {
- final JsonObject row = new JsonObject();
- row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
- row.addProperty("accountNo", (String) loan.get("account_no"));
- row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
- result.add(row);
- }
- return gson.toJson(result);
- }
+ "(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", "r.savings_apply_amount",
+ "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id",
+ "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at",
+ // The Demand Draft this claim is collected as — the only settlement route for new claims.
+ // loan/savings/cash columns above remain for claims settled before those routes were retired.
+ "r.dd_apply_amount", "r.dd_beneficiary_name", "r.dd_payable_at_branch", "r.dd_payable_at_city", "r.dd_issue_request_id",
+ "r.dd_number" };
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
@@ -147,18 +122,10 @@ public class DividendClaimRequestApiResource {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
- final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
- final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
- final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
- final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
- // Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1),
- // while a dividend share often works out to paise -- floor to the nearest rupee (never
- // trusting the frontend to have already done so) so the denomination breakdown the
- // cashier counts at disburse time can always match exactly; any leftover paise is simply
- // not claimed here and stays part of the lock's balance for a future claim.
- final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root))
- .setScale(0, RoundingMode.FLOOR);
- final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
+ final BigDecimal ddApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("ddApplyAmount", root));
+ final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root));
+ final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root));
+ final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root));
if (lockId == null) {
throw apiError(400, "lockId is required");
}
@@ -171,53 +138,28 @@ public class DividendClaimRequestApiResource {
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
- final Object lockSavingsAccountId = lock.get("savings_account_id");
- // The share account isn't required to have a linked savings account up front any more --
- // if the member wants a savings credit and none is linked, the clerk supplies which
- // account to credit right here.
- Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
- : savingsAccountIdOverride;
- if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
- if (resolvedSavingsAccountId == null) {
- throw apiError(400,
- "This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
- }
- final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
- resolvedSavingsAccountId);
- if (savingsClientId == null || !clientId.equals(savingsClientId)) {
- throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
- }
+ if (ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
+ throw apiError(400, "ddApplyAmount is required and must be greater than zero");
+ }
+ if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
+ throw apiError(400,
+ "Amount claimed (" + ddApplyAmount + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
+ }
+ if (ddBeneficiaryName == null) {
+ throw apiError(400, "ddBeneficiaryName is required — a Demand Draft cannot be drawn without a payee");
+ }
+ if (ddPayableAtBranch == null || ddPayableAtCity == null) {
+ throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft");
}
- final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
- if (total.compareTo(BigDecimal.ZERO) <= 0) {
- throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
- }
- if (total.compareTo(unclaimedAmount) > 0) {
- throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
- }
-
- if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
- if (loanId == null) {
- throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
- }
- final Map loan = jdbcTemplate
- .queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
- .orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
- if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
- throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
- }
- if (((Number) loan.get("loan_status_id")).intValue() != 300) {
- throw apiError(400, "Loan " + loanId + " is not Active");
- }
- }
-
- if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
- if (tokNo == null || tokNo.isBlank()) {
- throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero");
- }
- validateTokenUniqueToday(tokNo, null);
+ // A draft can only be raised if there is stock to raise it on. Checked here rather than at
+ // approval so the clerk finds out while the member is still at the counter, instead of the
+ // claim sitting pending and failing later.
+ final Long availableLeaflets = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'", Long.class);
+ if (availableLeaflets == null || availableLeaflets == 0) {
+ throw apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD.");
}
final Long existingPending = jdbcTemplate.queryForObject(
@@ -226,40 +168,85 @@ public class DividendClaimRequestApiResource {
throw apiError(409, "A claim request for this dividend is already pending review.");
}
+ validateNotGuarantorOfOverdueLoan(clientId);
+
+ // loan/savings/cash apply amounts are written as zero: those routes are retired, but the
+ // columns remain for claims settled through them historically.
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_share_dividend_claim_request
- (lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
- cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
+ (lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount,
+ dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
+ status, clerk_id, clerk_name, submitted_at)
+ VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
RETURNING id
- """, Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
- cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
- cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null);
+ """, Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
+ currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
/**
- * Ensures the token number is unique for today's date across both this table and
- * {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same
- * Transaction Entry field, so the two pools must never collide.
+ * Blocks a dividend claim while the member stands as guarantor on someone else's overdue loan.
+ *
+ * A guarantee is a promise to pay if the borrower does not, and the guarantor's share capital is the security
+ * behind it — the same capital this dividend was earned on. Paying the dividend out by draft while the guaranteed
+ * loan is in arrears hands over money the society may be about to demand back from the very same member, and once
+ * a draft is drawn there is nothing left to recover it from.
+ *
+ * Only OVERDUE guarantees block. A guarantee on a loan being repaid on time is a contingent obligation that has
+ * not crystallised, and withholding a dividend against it would penalise a member for a loan nobody has defaulted
+ * on. The block lifts on its own the moment the borrower clears the arrears.
+ *
+ * Deliberately not a silent hold: the clerk is told which loan, whose, and how much is overdue, so they can tell
+ * the member standing at the counter why — and so the member can go to the borrower about it.
*/
- private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
- final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request "
- + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
- final List params = new ArrayList<>();
- params.add(tokNo);
- if (excludeId != null) {
- sql.append(" AND id <> ?");
- params.add(excludeId);
+ private void validateNotGuarantorOfOverdueLoan(final Long clientId) {
+ final List> overdueGuarantees = jdbcTemplate.queryForList("""
+ SELECT l.account_no,
+ COALESCE(b.display_name, '(unnamed borrower)') AS borrower_name,
+ COALESCE(SUM(ri.principal_amount + ri.interest_amount
+ - COALESCE(ri.principal_completed_derived, 0)
+ - COALESCE(ri.interest_completed_derived, 0)), 0) AS overdue_amount
+ FROM m_guarantor g
+ JOIN m_loan l ON l.id = g.loan_id
+ LEFT JOIN m_client b ON b.id = l.client_id
+ JOIN m_loan_repayment_schedule ri ON ri.loan_id = l.id
+ WHERE g.entity_id = ?
+ AND g.type_enum = 1
+ AND g.is_active = true
+ AND l.loan_status_id IN (300, 303, 304, 700)
+ AND ri.completed_derived = false
+ AND ri.duedate < CURRENT_DATE
+ GROUP BY l.account_no, b.display_name
+ HAVING COALESCE(SUM(ri.principal_amount + ri.interest_amount
+ - COALESCE(ri.principal_completed_derived, 0)
+ - COALESCE(ri.interest_completed_derived, 0)), 0) > 0
+ ORDER BY overdue_amount DESC
+ """, clientId);
+
+ if (overdueGuarantees.isEmpty()) {
+ return;
}
- final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
- final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request "
- + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo);
- final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount);
- if (total > 0) {
- throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
+
+ final StringBuilder detail = new StringBuilder();
+ for (final Map guarantee : overdueGuarantees) {
+ if (detail.length() > 0) {
+ detail.append("; ");
+ }
+ detail.append("loan ").append(guarantee.get("account_no")).append(" (").append(guarantee.get("borrower_name"))
+ .append(") overdue ").append(guarantee.get("overdue_amount"));
}
+
+ throw apiError(409, "This member is an active guarantor on an overdue loan, so their dividend cannot be paid out yet: "
+ + detail + ". The dividend stays unclaimed until the arrears are cleared or the guarantee ends.");
+ }
+
+ private String trimToNull(final String value) {
+ if (value == null) {
+ return null;
+ }
+ final String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
}
private BigDecimal parseAmount(final String value) {
@@ -317,373 +304,207 @@ public class DividendClaimRequestApiResource {
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
}
+ // 409 rather than 403: the user has every right to approve dividend claims — just not this
+ // one, because they raised it. A 403 reads as "you lack permission", which sends the checker
+ // to an administrator to fix access that was never the problem.
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
- throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
+ throw apiError(409, "You raised this dividend claim, so you cannot also approve it. Two different people must handle a "
+ + "payout: one to raise it, another to check it. Ask a different manager to approve this claim.");
}
final Long lockId = ((Number) record.get("lock_id")).longValue();
final Map lock = jdbcTemplate
- .queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
- .findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
+ .queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream().findFirst()
+ .orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
}
+ // Re-checked at approval, not just at submission: a claim can sit pending for days, and a
+ // guaranteed loan can fall into arrears in that window. Approval is the point the draft is
+ // actually drawn, so it is the last moment the society can still hold the money back.
+ validateNotGuarantorOfOverdueLoan(((Number) record.get("client_id")).longValue());
+
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
- final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
- final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
- final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
- final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
- if (total.compareTo(unclaimedAmount) > 0) {
+ final BigDecimal ddApplyAmount = (BigDecimal) record.get("dd_apply_amount");
+ if (ddApplyAmount == null || ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
+ throw apiError(409, "This claim has no Demand Draft amount recorded.");
+ }
+ if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(409,
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
}
- final Object claimSavingsAccountId = record.get("savings_account_id");
- if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
- throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
- }
final LocalDate today = DateUtils.getBusinessLocalDate();
- final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
+ final Map issuedDraft = issueDemandDraftFromDividendPayable(id, lockId, record, ddApplyAmount, currencyCode, today,
+ currentUser);
- if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
- final Long loanId = ((Number) record.get("loan_id")).longValue();
- final JsonObject payload = new JsonObject();
- payload.addProperty("locale", "en");
- payload.addProperty("dateFormat", "yyyy-MM-dd");
- payload.addProperty("transactionDate", today.toString());
- payload.addProperty("transactionAmount", loanApplyAmount);
- payload.addProperty("note", note);
- final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
- .withJson(payload.toString()).build();
- final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
- redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
- }
-
- if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
- final JsonObject payload = new JsonObject();
- payload.addProperty("locale", "en");
- payload.addProperty("dateFormat", "yyyy-MM-dd");
- payload.addProperty("transactionDate", today.toString());
- payload.addProperty("transactionAmount", savingsApplyAmount);
- payload.addProperty("note", note);
- payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
- final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
- final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
- .withJson(payload.toString()).build();
- final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
- redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
- }
-
- if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
- payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
- }
-
- final BigDecimal remainder = unclaimedAmount.subtract(total);
+ final BigDecimal remainder = unclaimedAmount.subtract(ddApplyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
- jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
- lockId);
+ jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
} else {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
}
- final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
- cash_status = ?
+ dd_issue_request_id = ?, dd_number = ?
WHERE id = ? AND status = 'PENDING'
- """, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
+ """, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
+ issuedDraft.get("issueRequestId"), issuedDraft.get("ddNumber"), id);
final JsonObject response = new JsonObject();
response.addProperty("resourceId", id);
response.addProperty("status", "APPROVED");
- response.addProperty("totalDisbursed", total);
+ response.addProperty("totalDisbursed", ddApplyAmount);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
+ response.addProperty("ddNumber", (String) issuedDraft.get("ddNumber"));
+ response.addProperty("ddIssueRequestId", (Long) issuedDraft.get("issueRequestId"));
return gson.toJson(response);
}
- // ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ──
-
/**
- * Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
- * Entry types the clerk-supplied token number here to resolve the approved cash amount before
- * disbursing it, instead of the manager handing over cash directly at approval time.
+ * Draws the Demand Draft the member is collecting their dividend as, funded by the society's Dividend Payable
+ * liability rather than by anything the member pays in.
+ *
+ * A DD raised at the counter debits the purchaser — their savings account, or cash they hand over — because they
+ * are buying the instrument. Here the society already owes the money, so the draft is funded from Dividend Payable
+ * and the member pays nothing: no charge, no GST. That is what payment_method 'DIVIDEND' marks, and why those
+ * columns are written as zero.
+ *
+ * The leaflet is taken from inventory and marked ISSUED exactly as the normal flow does, so the draft carries a
+ * real DD number, appears in DD reconciliation, and can be stopped or reprinted like any other.
*/
- @GET
- @Path("/by-token/{token}")
- @Produces(MediaType.APPLICATION_JSON)
- public String getByToken(@PathParam("token") final String token) {
- final List> rows = jdbcTemplate.queryForList("""
- SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount,
- r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at
- FROM m_share_dividend_claim_request r
- JOIN m_client c ON c.id = r.client_id
- WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE
- ORDER BY r.submitted_at DESC LIMIT 1
- """, token);
+ private Map issueDemandDraftFromDividendPayable(final Long claimId, final Long lockId,
+ final Map record, final BigDecimal amount, final String currencyCode, final LocalDate today,
+ final AppUser currentUser) {
- if (rows.isEmpty()) {
- throw apiError(404, "Token '" + token + "' not found for today. Please check the token number.");
- }
+ // Oldest series first, and locked so two concurrent approvals cannot take the same leaflet.
+ final Map leaflet = jdbcTemplate.queryForList("""
+ SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
+ ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
+ """).stream().findFirst()
+ .orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory."));
+ final Long leafletId = ((Number) leaflet.get("id")).longValue();
+ final String ddNumber = (String) leaflet.get("dd_number");
- final Map row = rows.get(0);
- final String status = String.valueOf(row.get("status"));
- final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status"));
+ final Long clientId = ((Number) record.get("client_id")).longValue();
+ final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
- if ("PENDING".equals(status)) {
- throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet.");
- }
- if ("REJECTED".equals(status)) {
- throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed.");
- }
- if (!"APPROVED".equals(status)) {
- throw apiError(422, "Token '" + token + "' has unexpected status: " + status);
- }
- if ("DISBURSED".equals(cashStatus)) {
- throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
- }
- if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
- throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement.");
- }
- return gson.toJson(row);
- }
+ // APPROVED outright: the manager approving this claim IS the check on it, and a second
+ // maker-checker round on the draft would leave a member's paid dividend in limbo.
+ // client_id still records WHOSE dividend this is — the entitlement belongs to them and the
+ // draft has to be traceable back to the member. walkin_purchaser_name names who actually
+ // raised it, which is the society, so the two roles stay distinguishable.
+ final Long issueRequestId = jdbcTemplate.queryForObject("""
+ INSERT INTO m_sponsor_dd_issue_request
+ (leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city,
+ face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date,
+ payment_method, status, maker_id, created_date, checker_id, checker_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'APPROVED', ?, ?, ?, ?)
+ RETURNING id
+ """, Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"),
+ record.get("dd_payable_at_branch"), record.get("dd_payable_at_city"), amount, branchId, java.sql.Date.valueOf(today),
+ ((Number) record.get("clerk_id")).longValue(), Timestamp.from(Instant.now()), currentUser.getId(),
+ Timestamp.from(Instant.now()));
- // ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
+ // The leaflet carries its own copy of the draft's details, and the DD register reads from
+ // there rather than from the issue request — so setting the status alone leaves the draft
+ // listed with every column blank. Mirrors what the ordinary issue flow writes.
+ //
+ // issued_to_client_id is deliberately left NULL and the purchaser named as the society
+ // instead. On an ordinary draft the client IS the purchaser — they walked in and bought it.
+ // Here the society is paying the member their own dividend, so recording the member as
+ // purchaser would read as though they had bought the draft, and the register would show the
+ // same name as both purchaser and beneficiary with nothing to distinguish a
+ // society-initiated draft from a counter sale.
+ jdbcTemplate.update("""
+ UPDATE m_sponsor_dd_leaflet
+ SET status = 'ISSUED', face_value = ?, issued_to_client_id = NULL,
+ walkin_purchaser_name = ?, beneficiary_name = ?,
+ payable_at_branch = ?, payable_at_city = ?, issuing_branch_id = ?, issued_date = ?,
+ advice_status = 'NOT_SENT'
+ WHERE id = ?
+ """, amount, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), record.get("dd_payable_at_branch"),
+ record.get("dd_payable_at_city"), branchId, java.sql.Date.valueOf(today), leafletId);
- /**
- * Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
- * approve time, so this step is purely a physical-cash-handover record with double-disbursement
- * protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction.
- */
- @POST
- @Path("/{id}/disburse")
- @Consumes(MediaType.APPLICATION_JSON)
- @Produces(MediaType.APPLICATION_JSON)
- @Transactional
- public String disburse(@PathParam("id") final Long id, final String body) {
- final AppUser currentUser = context.authenticatedUser();
- final Map record = jdbcTemplate
- .queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?",
- id)
- .stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id));
+ postDividendPayableToDdPayable(claimId, lockId, ddNumber, currencyCode, amount, today, currentUser);
- final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status"));
- if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
- throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus);
- }
-
- String denominationData = null;
- BigDecimal transactionAmount = null;
- if (body != null && !body.isBlank()) {
- final JsonElement root = fromJsonHelper.parse(body);
- denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
- transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root));
- }
- if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
- throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
- }
-
- // cash_apply_amount is floored to whole rupees at submit time now (see submit() above),
- // but a claim created before that fix may still carry paise -- floor here too so an older
- // claim can still be disbursed in whole-rupee cash rather than being stuck forever.
- final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
- final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR);
- if (disbursableAmount.compareTo(transactionAmount) != 0) {
- throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of "
- + disbursableAmount + " (cash can only be handed over in whole rupees).");
- }
-
- final long denomTotal = denominationTotal(denominationData);
- if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
- throw apiError(400,
- "Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount);
- }
-
- final int updated = jdbcTemplate.update("""
- UPDATE m_share_dividend_claim_request SET
- cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ?
- WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT'
- """, currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id);
-
- if (updated == 0) {
- throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented.");
- }
-
- if (denominationData != null && !denominationData.isBlank()) {
- final Long clientId = ((Number) record.get("client_id")).longValue();
- final Long lockId = ((Number) record.get("lock_id")).longValue();
- saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser);
- }
-
- return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
- }
-
- /** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
- private long denominationTotal(final String denominationData) {
- if (denominationData == null || denominationData.isBlank()) {
- return 0;
- }
- final JsonObject denomMap;
- try {
- denomMap = gson.fromJson(denominationData, JsonObject.class);
- } catch (final Exception e) {
- throw apiError(400, "denominationData is not valid JSON");
- }
- if (denomMap == null) {
- return 0;
- }
- long total = 0;
- for (final Map.Entry entry : denomMap.entrySet()) {
- final String[] parts = entry.getKey().split(":", 2);
- if (parts.length != 2) {
- continue;
- }
- final int value;
- final int count;
- try {
- value = Integer.parseInt(parts[1]);
- count = entry.getValue().getAsInt();
- } catch (final Exception e) {
- continue;
- }
- if (count > 0) {
- total += (long) value * count;
- }
- }
- return total;
+ final Map result = new java.util.LinkedHashMap<>();
+ result.put("issueRequestId", issueRequestId);
+ result.put("ddNumber", ddNumber);
+ return result;
}
/**
- * Records the cashier's note/coin breakdown for a dividend cash disbursement into
- * {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
- * transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
- * claim request's own id is used as the stable txn identifier for grouping.
+ * Moves the member's entitlement from Dividend Payable to the DD liability: the society still owes the money, but
+ * now against a numbered instrument rather than an unclaimed dividend. Cleared when the draft is presented and
+ * paid, through the normal DD settlement.
+ *
+ * Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration
+ * and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
*/
- private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
- final String denominationData, final AppUser cashier) {
- final JsonObject denomMap;
- try {
- denomMap = gson.fromJson(denominationData, JsonObject.class);
- } catch (final Exception e) {
- return;
- }
- if (denomMap == null || denomMap.size() == 0) {
- return;
- }
+ private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
+ final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
- for (final Map.Entry entry : denomMap.entrySet()) {
- final int count;
- try {
- count = entry.getValue().getAsInt();
- } catch (final Exception e) {
- continue;
- }
- if (count <= 0) {
- continue;
- }
-
- final String[] parts = entry.getKey().split(":", 2);
- if (parts.length != 2) {
- continue;
- }
- final String kind = parts[0];
- final int value;
- try {
- value = Integer.parseInt(parts[1]);
- } catch (final Exception e) {
- continue;
- }
-
- final List ids = jdbcTemplate.queryForList(
- "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1",
- Long.class, value, kind);
- if (ids.isEmpty()) {
- continue;
- }
- final Long denomId = ids.get(0);
-
- jdbcTemplate.update("""
- INSERT INTO m_savings_txn_denomination
- (txn_id, savings_account_id, account_no, client_id, denomination_id,
- denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
- VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
- """, claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count,
- BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername());
- }
- }
-
- /**
- * Fineract's savings deposit command mandates paymentTypeId even for an internal posting with
- * no real customer payment instrument -- resolves the system-seeded "Dividend Claim
- * Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which
- * would break on any tenant seeded differently.
- */
- private Long resolveDividendClaimPaymentTypeId() {
- final Long paymentTypeId = jdbcTemplate
- .queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream()
+ final Long payableDividendsAccountId = jdbcTemplate
+ .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
+ FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
+ .stream().findFirst().orElse(null);
+ final Long dividendPaidAccountId = jdbcTemplate
+ .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
+ FINANCIAL_ACTIVITY_DIVIDEND_PAID)
+ .stream().findFirst().orElse(null);
+ final Long ddPayableAccountId = jdbcTemplate
+ .queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
.findFirst().orElse(null);
- if (paymentTypeId == null) {
- throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations.");
- }
- return paymentTypeId;
- }
-
- /**
- * The loan repayment / savings deposit command just posted its own native GL entries assuming
- * the money came out of whichever account is that product's actual Fund Source (not always
- * literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its
- * own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with
- * a second manual entry pair (which would leave that fund-source account appearing twice, once
- * debited then immediately credited back -- a confusing "wash" no real accountant would post),
- * this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction
- * ends up with exactly the entries a real accountant would expect: Dividend Payable debited,
- * Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still
- * updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of
- * the two ids is non-null per call.
- */
- private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) {
- final Long payableDividendsAccountId = jdbcTemplate
- .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
- FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
- .stream().findFirst().orElse(null);
- if (payableDividendsAccountId == null) {
- return;
- }
- if (loanTransactionId != null) {
- jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
- payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
- }
- if (savingsTransactionId != null) {
- jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
- payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
- }
- }
-
- /**
- * A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
- * correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
- * member's hand.
- */
- private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
- final LocalDate entryDate) {
- final Long payableDividendsAccountId = jdbcTemplate
- .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
- FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
- .stream().findFirst().orElse(null);
- final Long cashAtVaultAccountId = jdbcTemplate
- .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
- FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
- .stream().findFirst().orElse(null);
- if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
+ if (payableDividendsAccountId == null || ddPayableAccountId == null) {
return;
}
- final AppUser currentUser = context.authenticatedUser();
- final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
- final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
- final String description = "Dividend claim #" + claimRequestId + " disbursed";
+ final java.sql.Date entryDate = java.sql.Date.valueOf(today);
+ final String transactionId = "DIVDD" + claimId;
+ // Stamped with the financial year the dividend was DECLARED for, not the year it is being
+ // claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot
+ // say how much of each year's declaration has actually been claimed.
+ final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #"
+ + claimId + " (lock #" + lockId + ")";
+
+ if (dividendPaidAccountId == null) {
+ // Dividend Paid not mapped: post straight across, exactly as before this account existed.
+ // A society that has not configured it still gets a correct payout, just without the
+ // separate "how much has been claimed" measure.
+ postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description,
+ currentUser);
+ return;
+ }
+
+ // Routed through Dividend Paid rather than posted straight across, so the society can read
+ // off what has been claimed and what is still unclaimed as two separate balances instead of
+ // only the net of Dividend Payable.
+ //
+ // Two pairs, in the order the money moves, each with its own description — reading Dividend
+ // Paid's ledger has to show which line settled the member's entitlement and which raised the
+ // draft, and one shared description makes the two indistinguishable.
+ postJournalEntryPair(payableDividendsAccountId, dividendPaidAccountId, amount, currencyCode, transactionId, entryDate,
+ description + " (member's entitlement settled)", currentUser);
+ // The obligation does not end here: the society still owes the money until the draft is
+ // presented and paid, which is why this moves to Sponsor DD Payable rather than to cash.
+ // Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed.
+ postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate,
+ description + " (draft raised)", currentUser);
+ }
+
+ /**
+ * Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction
+ * the money travelled and the ledger reads in order.
+ */
+ private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
+ final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
+ final AppUser currentUser) {
+
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -691,13 +512,12 @@ public class DividendClaimRequestApiResource {
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
- this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
- currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
- this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
- currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
+ jdbcTemplate.update(insertSql, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount,
+ description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
+ jdbcTemplate.update(insertSql, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, amount,
+ description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
}
- // ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
@POST
@Path("/{id}/reject")
@@ -742,6 +562,7 @@ public class DividendClaimRequestApiResource {
private Map loadRecord(final Long id) {
final List> rows = jdbcTemplate.queryForList("""
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
+ dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
status, clerk_id, clerk_name
FROM m_share_dividend_claim_request WHERE id = ?
""", id);
@@ -751,6 +572,28 @@ public class DividendClaimRequestApiResource {
return rows.get(0);
}
+ /**
+ * The financial year of the declaration a lock came from — walking lock to per-member detail to declaration.
+ *
+ * Returns "unknown" rather than throwing if that chain is broken: a missing label must never stop a member being
+ * paid their dividend.
+ */
+ private String financialYearOfLock(final Long lockId) {
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT d.dividend_period_end_date
+ FROM m_share_dividend_lock l
+ JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
+ JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
+ WHERE l.id = ?
+ """, lockId);
+ if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
+ return "unknown";
+ }
+ final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
+ final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
+ return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
+ }
+
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java
index 89e7a78bc..43be84903 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java
@@ -51,11 +51,10 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
- * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
- * progress, full 3-year lock history, and members whose unclaimed dividend (see
- * {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
- * proactively apply it towards that EMI instead of waiting for the member to claim it or for it
- * to be forfeited to profit after 3 years.
+ * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout progress, full 3-year
+ * lock history, and members whose unclaimed dividend (see {@code m_share_dividend_lock}) sits alongside an overdue loan
+ * installment so the manager can proactively apply it towards that EMI instead of waiting for the member to claim it or
+ * for it to be forfeited to profit after 3 years.
*/
@Path("/v1/clerk/dividend-dashboard")
@Component
@@ -112,8 +111,7 @@ public class DividendDashboardApiResource {
final JsonObject result = new JsonObject();
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
- result.addProperty("pendingApprovalDeclarations",
- ((Number) declarationStats.get("pending_approval_declarations")).longValue());
+ result.addProperty("pendingApprovalDeclarations", ((Number) declarationStats.get("pending_approval_declarations")).longValue());
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
@@ -132,9 +130,9 @@ public class DividendDashboardApiResource {
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
/**
- * Every dividend ever declared (across every share product), so a manager tracking multiple
- * dividends at once can see -- per declaration -- how many members it covers and how far its
- * payout has actually progressed, not just whether it was approved.
+ * Every dividend ever declared (across every share product), so a manager tracking multiple dividends at once can
+ * see -- per declaration -- how many members it covers and how far its payout has actually progressed, not just
+ * whether it was approved.
*/
@GET
@Path("/declarations")
@@ -179,25 +177,34 @@ public class DividendDashboardApiResource {
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
/**
- * Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
- * counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
- * see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
- * profit). Optional {@code status} query param filters to one.
+ * Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a
+ * member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3
+ * years passed unclaimed, swept to the bank's profit). Optional {@code status} query param filters to one.
*/
@GET
@Path("/locks")
@Produces(MediaType.APPLICATION_JSON)
public String locks(@QueryParam("status") final String status) {
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
+ // Which declaration each lock came from. Once a member has more than one unclaimed dividend —
+ // this year's plus a prior year they never collected — amount and locked date alone cannot
+ // tell them apart, so the clerk has no way to say which year is being paid out. The join
+ // walks lock -> per-member detail -> declaration -> product to name the period and product.
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
- + "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
+ + "l.applied_loan_id, ln.account_no AS applied_loan_account_no, "
+ + "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name " //
+ "FROM m_share_dividend_lock l " //
+ "JOIN m_client c ON c.id = l.client_id " //
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
+ + "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " //
+ + "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " //
+ + "LEFT JOIN m_share_product p ON p.id = d.product_id " //
+ (filterByStatus ? "WHERE l.status = ? " : "") //
- + "ORDER BY l.locked_date DESC";
+ // Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that
+ // needs attention, and it is the one a member should be paid out of first.
+ + "ORDER BY d.dividend_period_end_date DESC NULLS LAST, l.locked_date DESC";
final List> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
: jdbcTemplate.queryForList(sql);
@@ -217,6 +224,17 @@ public class DividendDashboardApiResource {
final Object appliedLoanId = row.get("applied_loan_id");
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
+ final Object declarationId = row.get("declaration_id");
+ entry.addProperty("declarationId", declarationId == null ? null : ((Number) declarationId).longValue());
+ entry.addProperty("productName", (String) row.get("product_name"));
+ final Object periodStart = row.get("dividend_period_start_date");
+ final Object periodEnd = row.get("dividend_period_end_date");
+ entry.addProperty("periodStartDate", periodStart == null ? null : periodStart.toString());
+ entry.addProperty("periodEndDate", periodEnd == null ? null : periodEnd.toString());
+ // The financial year the dividend was earned in, taken from the period end so a
+ // declaration is labelled by the year it closes — "FY 2026-27", the way a member and an
+ // auditor both refer to it.
+ entry.addProperty("financialYear", periodEnd == null ? null : financialYearOf(periodEnd.toString()));
result.add(entry);
}
return gson.toJson(result);
@@ -260,17 +278,19 @@ public class DividendDashboardApiResource {
}
private JsonArray overdueLoansFor(final Long clientId) {
- final List> loans = jdbcTemplate.queryForList("""
- SELECT ln.id AS loan_id, ln.account_no,
- SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- - COALESCE(ri.principal_writtenoff_derived, 0), 0)
- + GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- - COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
- FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
- WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
- GROUP BY ln.id, ln.account_no
- ORDER BY overdue_amount DESC
- """, clientId);
+ final List> loans = jdbcTemplate.queryForList(
+ """
+ SELECT ln.id AS loan_id, ln.account_no,
+ SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
+ - COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ + GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
+ - COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
+ FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
+ WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
+ GROUP BY ln.id, ln.account_no
+ ORDER BY overdue_amount DESC
+ """,
+ clientId);
final JsonArray result = new JsonArray();
for (final Map loan : loans) {
final JsonObject row = new JsonObject();
@@ -285,14 +305,13 @@ public class DividendDashboardApiResource {
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
/**
- * Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
- * loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
- * and file a claim. Since the dividend was never credited into any savings account (it's a
- * pure "Dividend Payable" GL liability until claimed -- see
- * {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
- * make: this posts a native loan repayment (so the loan's own schedule/state stays fully
- * native and correct) and then redirects the debit leg it assumed the cash came from to the
- * Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
+ * Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue loan EMI (see
+ * {@code list()} above) and applies it without waiting for the member to visit and file a claim. Since the dividend
+ * was never credited into any savings account (it's a pure "Dividend Payable" GL liability until claimed -- see
+ * {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to make: this posts a
+ * native loan repayment (so the loan's own schedule/state stays fully native and correct) and then redirects the
+ * debit leg it assumed the cash came from to the Dividend Payable liability instead -- identical mechanic to a
+ * claim's loan leg.
*/
@POST
@Path("/{lockId}/apply-to-loan")
@@ -341,15 +360,14 @@ public class DividendDashboardApiResource {
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", applyAmount);
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
- final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
- .withJson(payload.toString()).build();
+ final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId).withJson(payload.toString())
+ .build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
- jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
- lockId);
+ jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
} else {
jdbcTemplate.update("""
UPDATE m_share_dividend_lock
@@ -366,16 +384,14 @@ public class DividendDashboardApiResource {
}
/**
- * The loan repayment just posted above assumed the money came out of whichever account is
- * this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a
- * Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead)
- * -- it didn't really. Rather than reverse that with a second manual entry pair (which would
- * leave that fund-source account appearing twice, once debited then immediately credited back
- * -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's
- * account in place to Dividend Payable, so the transaction ends up with exactly the entries a
- * real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited,
- * nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend
- * Payable financial activity.
+ * The loan repayment just posted above assumed the money came out of whichever account is this loan product's
+ * actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product
+ * debits its own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with a second
+ * manual entry pair (which would leave that fund-source account appearing twice, once debited then immediately
+ * credited back -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's account
+ * in place to Dividend Payable, so the transaction ends up with exactly the entries a real accountant would expect:
+ * Dividend Payable debited, Loan Portfolio/Income credited, nothing else touched. Skipped (loan still updated) if
+ * the bank hasn't mapped the Dividend Payable financial activity.
*/
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
final Long payableDividendsAccountId = jdbcTemplate
@@ -389,6 +405,16 @@ public class DividendDashboardApiResource {
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
+ /**
+ * The Indian financial year an ISO date falls in, as "2026-27". April starts the year, so a declaration ending
+ * 31 March 2027 is FY 2026-27 — the label a member and an auditor would both use for it.
+ */
+ private String financialYearOf(final String isoDate) {
+ final LocalDate date = LocalDate.parse(isoDate);
+ final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
+ return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
+ }
+
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java
index ffcccd5c2..a64e785b3 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java
@@ -301,10 +301,24 @@ public class ShareAccountData implements Serializable, AccountData {
minimumActivePeriodFrequencyTypeOption, currenMarketPrice);
}
+ /**
+ * Whether the member's money has actually been taken and the shares issued — either by applying a cash collection
+ * or by debiting their savings.
+ *
+ * Distinct from the account's own status: an account is Approved and then Activated before anything is settled, and
+ * the purchase transaction reads Approved from the moment the account is approved. Only this says money has moved,
+ * which is what the Transactions, Passbook and Charges views actually describe.
+ */
+ private Boolean collectionApplied;
+
public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) {
this.currentMarketPrice = currentMarketPrice;
}
+ public void setCollectionApplied(final Boolean collectionApplied) {
+ this.collectionApplied = collectionApplied;
+ }
+
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
this.linkedLoanId = linkedLoanId;
this.linkedLoanAccountNo = linkedLoanAccountNo;
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java
index 79953b34d..70f6dd8fc 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java
@@ -121,10 +121,15 @@ public class ShareAccountCharge extends AbstractPersistableCustom {
this.amountOrPercentage = chargeAmount;
if (this.chargeCalculation.equals(ChargeCalculationType.FLAT.getValue())) {
this.percentage = null;
- this.amount = BigDecimal.ZERO;
+ // A flat charge is owed in full the moment it is attached: its amount IS the
+ // configured fee, and all of it is outstanding until something pays it. Leaving
+ // both at zero (as this once did) made the entry fee invisible to every consumer
+ // that reads amount/amount_outstanding_derived — the share-collection settlement
+ // then asked the member for the share value alone and the fee went uncollected.
+ this.amount = chargeAmount;
this.amountPercentageAppliedTo = null;
this.amountPaid = null;
- this.amountOutstanding = BigDecimal.ZERO;
+ this.amountOutstanding = calculateOutstanding();
this.amountWaived = null;
this.amountWrittenOff = null;
} else if (this.chargeCalculation.equals(ChargeCalculationType.PERCENT_OF_AMOUNT.getValue())) {
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java
index 37e77ca5f..d37112a9c 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java
@@ -31,11 +31,10 @@ import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.jdbc.core.JdbcTemplate;
/**
- * Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
- * release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
- * forfeited — the hold on the member's savings account is released and the amount is swept back
- * into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
- * the actual GL/balance mechanics.
+ * Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year release_due_date has passed with
+ * no Bank Manager action (via the Dividend Dashboard) is forfeited — the hold on the member's savings account is
+ * released and the amount is swept back into the bank's profit. See
+ * {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for the actual GL/balance mechanics.
*/
@Slf4j
@RequiredArgsConstructor
@@ -47,8 +46,13 @@ public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final List exceptions = new ArrayList<>();
+ // 'UNCLAIMED', not 'LOCKED': changelog 0461 reworked the lock from a savings hold into a pure
+ // "Dividend Payable" GL liability and renamed the initial status accordingly (see
+ // makeDividendsClaimable, which inserts 'UNCLAIMED'). forfeitDividendLock already guards on
+ // 'UNCLAIMED'; this query was the last place still looking for the old name, so the job silently
+ // selected zero rows and nothing was ever forfeited.
final List lockIds = this.jdbcTemplate.queryForList(
- "SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
+ "SELECT id FROM m_share_dividend_lock WHERE status = 'UNCLAIMED' AND release_due_date <= CURRENT_DATE", Long.class);
for (final Long lockId : lockIds) {
try {
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java
index 0e257bcf5..1e1c7dcc1 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java
@@ -654,18 +654,25 @@ public class ShareAccountDataSerializer {
}
private void handlechargesOnActivation(final ShareAccount account) {
- Set charges = account.getCharges();
- for (ShareAccountCharge charge : charges) {
- if (charge.isActive() && charge.isShareAccountActivation()) {
- charge.markAsFullyPaid();
- }
- }
-
+ // The entry fee is deliberately NOT marked paid here. Activating the account decides the
+ // member may hold shares; it does not collect anything, and nobody has yet said where the
+ // money comes from — a cash receipt at the counter, or a debit from the member's savings.
+ //
+ // Marking it paid at activation left amount_paid_derived at the full fee and
+ // amount_outstanding_derived at zero while nothing had actually been received, so the
+ // settlement step read the fee as already settled and asked the member for the share value
+ // alone ("shares 400.00 + charges 0"). The fee was then never collected at all.
+ //
+ // It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource
+ // (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set
+ // amount_paid_derived and amount_outstanding_derived alongside the flag.
+ //
+ // The charge transaction is still dated to the activation date — that records when the fee
+ // became due, which is a separate thing from when it was paid.
Set transactions = account.getChargeTransactions();
for (ShareAccountTransaction transaction : transactions) {
if (transaction.isChargeTransaction()) {
transaction.updateTransactionDate(account.getActivatedDate());
- transaction.updateAmountPaid(transaction.amount());
}
}
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java
index 038249f89..a77facb3e 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java
@@ -140,6 +140,10 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final ShareProductData productData = (ShareProductData) service.retrieveOne(data.getProductId(), false);
final BigDecimal currentMarketPrice = deriveMarketPrice(productData);
data.setCurrentMarketPrice(currentMarketPrice);
+ // Read separately rather than added to the row mapper, which is shared with the list and
+ // template queries that have no use for it.
+ data.setCollectionApplied(this.jdbcTemplate.queryForObject("SELECT COALESCE(collection_applied, false) FROM m_share_account "
+ + " WHERE id = ?", Boolean.class, id));
if (!includeTemplate) {
Collection dividends = this.retrieveAssociatedDividends(id);
data.setDividends(dividends);
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java
index ffc9f7f8a..30d621458 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java
@@ -40,8 +40,13 @@ import org.springframework.transaction.annotation.Transactional;
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
+
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
+ // Fineract convention: 1 = CREDIT, 2 = DEBIT.
+ private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
+ private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
+
private final ShareAccountDividendRepository shareAccountDividendRepository;
private final SavingsAccountDomainService savingsAccountDomainService;
private final SavingsAccountAssembler savingsAccountAssembler;
@@ -62,12 +67,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
}
/**
- * Real-world UCB accounting: an approved dividend is never credited into a member's savings
- * account up front -- it stays purely a "Dividend Payable" GL liability (see
- * {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
- * member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
- * dividend past its 3-year deadline has no savings hold to release and no account balance to
- * deduct -- the liability is simply written off directly against the bank's profit.
+ * Real-world UCB accounting: an approved dividend is never credited into a member's savings account up front -- it
+ * stays purely a "Dividend Payable" GL liability (see
+ * {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the member actually
+ * claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed dividend past its 3-year deadline has no
+ * savings hold to release and no account balance to deduct -- the liability is simply written off directly against
+ * the bank's profit.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -84,22 +89,55 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
postForfeitureJournalEntry(lockId, currencyCode, amount);
- this.jdbcTemplate.update(
- "UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
+ this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
}
/**
- * Debit writes off the Dividend Payable liability (it was never going to be paid); credit
- * increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
- * Reads currency straight off the lock row (set from the share account at creation time) --
- * this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
- * both GL accounts yet, exactly like the declaration-time Reserve entry.
+ * The financial year of the declaration a lock came from. "unknown" rather than an exception if the chain is
+ * broken — a missing label must not stop the forfeiture from posting.
+ */
+ private String financialYearOfLock(final Long lockId) {
+ final java.util.List> rows = this.jdbcTemplate.queryForList("""
+ SELECT d.dividend_period_end_date
+ FROM m_share_dividend_lock l
+ JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
+ JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
+ WHERE l.id = ?
+ """, lockId);
+ if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
+ return "unknown";
+ }
+ final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
+ final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
+ return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
+ }
+
+ /**
+ * Writes off a dividend nobody came to claim.
+ *
+ *
+ * Dr Dividend Payable — the obligation ends; it was never going to be paid
+ * Cr Dividend Forfeited Income
+ *
+ *
+ * Recognised as income rather than credited to equity directly, so the recovery appears in that year's profit and
+ * loss where it can be seen and reported. The year-end closing job then closes it into Bank Profit along with every
+ * other income account, which is the same equity account the dividend was declared out of — so the money completes
+ * its round trip, by way of the P&L.
+ *
+ * Currency comes straight off the lock row (set from the share account when the lock was created), so this never
+ * depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the
+ * declaration-time entries.
*/
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
final Long payableDividendsAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
+ // Credited to Dividend Forfeited Income, which the year-end closing job then closes into Bank
+ // Profit along with every other income account — so the money still ends up back in the
+ // equity account the dividend was declared out of, but by way of the P&L, where a forfeited
+ // dividend is visible as that year's income rather than appearing in equity with no trace.
final Long forfeitedIncomeAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
@@ -112,7 +150,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVFORFEIT" + lockId;
- final String description = "Dividend forfeited to profit — share dividend lock #" + lockId;
+ // Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in —
+ // a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each
+ // year's declaration went unclaimed and came back to profit.
+ final String description = "FY " + financialYearOfLock(lockId) + " dividend forfeited to profit — share dividend lock #" + lockId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -120,10 +161,11 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
- this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
- currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
- this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
- currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
+ // Debit first, then credit, so the entry ids follow the direction the money travelled.
+ this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
+ amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
+ this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT,
+ amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java
index 6377c494e..1c7f1307f 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java
@@ -1,522 +1,528 @@
-/**
- * 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.shareaccounts.service;
-
-import jakarta.persistence.PersistenceException;
-import java.math.BigDecimal;
-import java.time.LocalDate;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import lombok.RequiredArgsConstructor;
-import org.apache.commons.lang3.StringUtils;
-import org.apache.commons.lang3.exception.ExceptionUtils;
-import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
-import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
-import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
-import org.apache.fineract.infrastructure.accountnumberformat.domain.EntityAccountType;
-import org.apache.fineract.infrastructure.core.api.JsonCommand;
-import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
-import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
-import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
-import org.apache.fineract.infrastructure.core.service.DateUtils;
-import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountApproveBusinessEvent;
-import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountCreateBusinessEvent;
-import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
-import org.apache.fineract.portfolio.account.service.AccountNumberGenerator;
-import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants;
-import org.apache.fineract.portfolio.note.domain.Note;
-import org.apache.fineract.portfolio.note.domain.NoteRepository;
-import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
-import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
-import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy;
-import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper;
-import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction;
-import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer;
-import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
-import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
-import org.springframework.dao.DataIntegrityViolationException;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.orm.jpa.JpaSystemException;
-
-@RequiredArgsConstructor
-public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareAccountWritePlatformService {
-
- private final ShareAccountDataSerializer accountDataSerializer;
-
- private final ShareAccountRepositoryWrapper shareAccountRepository;
-
- private final ShareProductRepositoryWrapper shareProductRepository;
-
- private final AccountNumberGenerator accountNumberGenerator;
-
- private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
-
- private final JournalEntryWritePlatformService journalEntryWritePlatformService;
-
- private final NoteRepository noteRepository;
-
- private final JdbcTemplate jdbcTemplate;
-
- private final BusinessEventNotifierService businessEventNotifierService;
-
- @Override
- public CommandProcessingResult createShareAccount(JsonCommand jsonCommand) {
- try {
- ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand);
- this.shareAccountRepository.saveAndFlush(account);
- generateAccountNumber(account);
- // GL entries are intentionally NOT posted at submission time.
- // Cashier collects cash and enters GL directly in the transaction entry
- // workstation: DR Cash → CR Share Sundry GL (share amount) and
- // DR Cash → CR Charges GL (charge amount). The approval step posts the
- // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
-
- businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account));
-
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(account.getId()) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- } catch (final PersistenceException dve) {
- Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
- handleDataIntegrityIssues(jsonCommand, throwable, dve);
- return CommandProcessingResult.empty();
- }
- }
-
- private void generateAccountNumber(final ShareAccount account) {
- if (account.isAccountNumberRequiresAutoGeneration()) {
- final AccountNumberFormat accountNumberFormat = this.accountNumberFormatRepository.findByAccountType(EntityAccountType.SHARES);
- account.updateAccountNumber(this.accountNumberGenerator.generate(account, accountNumberFormat));
- this.shareAccountRepository.save(account);
- }
- }
-
- private Map populateJournalEntries(final ShareAccount account, final Set transactions) {
- final Map accountingBridgeData = new HashMap<>();
- Boolean cashBasedAccounting = account.getShareProduct().getAccountingType() == 2;
- accountingBridgeData.put("cashBasedAccountingEnabled", cashBasedAccounting);
- accountingBridgeData.put("accrualBasedAccountingEnabled", Boolean.FALSE);
- accountingBridgeData.put("shareAccountId", account.getId());
- accountingBridgeData.put("shareProductId", account.getShareProduct().getId());
- accountingBridgeData.put("officeId", account.getOfficeId());
- accountingBridgeData.put("currencyCode", account.getCurrency().getCode());
- final List> newTransactionsMap = new ArrayList<>();
- accountingBridgeData.put("newTransactions", newTransactionsMap);
-
- for (ShareAccountTransaction transaction : transactions) {
- final Map transactionDto = new HashMap<>();
- transactionDto.put("officeId", account.getOfficeId());
- transactionDto.put("id", transaction.getId());
- transactionDto.put("date", transaction.getPurchasedDate());
- final Integer status = transaction.getTransactionStatus();
- final ShareAccountTransactionEnumData statusEnum = new ShareAccountTransactionEnumData(status.longValue(), null, null);
- final Integer type = transaction.getTransactionType();
- final ShareAccountTransactionEnumData typeEnum = new ShareAccountTransactionEnumData(type.longValue(), null, null);
- transactionDto.put("status", statusEnum);
- transactionDto.put("type", typeEnum);
- if (transaction.isPurchaseRejectedTransaction() || transaction.isRedeemTransaction()) {
- BigDecimal amount = transaction.amount();
- if (transaction.chargeAmount() != null) {
- amount = amount.add(transaction.chargeAmount());
- }
- transactionDto.put("amount", amount);
- } else {
- transactionDto.put("amount", transaction.amount());
- }
-
- transactionDto.put("chargeAmount", transaction.chargeAmount());
- transactionDto.put("paymentTypeId", null); // FIXME::make it cash
- // payment
- if (transaction.getChargesPaidBy() != null && !transaction.getChargesPaidBy().isEmpty()) {
- final List> chargesPaidData = new ArrayList<>();
- transactionDto.put("chargesPaid", chargesPaidData);
- Set chargesPaidBySet = transaction.getChargesPaidBy();
- for (ShareAccountChargePaidBy chargesPaidBy : chargesPaidBySet) {
- Map chargesPaidDto = new HashMap<>();
- chargesPaidDto.put("chargeId", chargesPaidBy.getChargeId());
- chargesPaidDto.put("sharesChargeId", chargesPaidBy.getShareChargeId());
- chargesPaidDto.put("amount", chargesPaidBy.getAmount());
- chargesPaidData.add(chargesPaidDto);
- }
- }
- newTransactionsMap.add(transactionDto);
- }
- return accountingBridgeData;
- }
-
- @SuppressWarnings("unchecked")
- @Override
- public CommandProcessingResult updateShareAccount(Long accountId, JsonCommand jsonCommand) {
- try {
- LocalDate transactionDate = DateUtils.getBusinessLocalDate();
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndUpdate(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.save(account);
- }
- // since we are reverting all journal entries we need to add journal
- // entries for application request
- if (changes.containsKey("reversalIds")) {
- ArrayList reversalIds = (ArrayList) changes.get("reversalIds");
- this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate);
- journalEntryWritePlatformService.createJournalEntriesForShares(
- populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
- changes.remove("reversalIds");
- }
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- } catch (final PersistenceException dve) {
- Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
- handleDataIntegrityIssues(jsonCommand, throwable, dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @Override
- public CommandProcessingResult applyAddtionalShares(final Long accountId, JsonCommand jsonCommand) {
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndApplyAddtionalShares(jsonCommand, account);
- ShareAccountTransaction transaction = null;
- if (!changes.isEmpty()) {
- this.shareAccountRepository.saveAndFlush(account);
- transaction = (ShareAccountTransaction) changes.get(ShareAccountApiConstants.additionalshares_paramname);
- transaction = account.getShareAccountTransaction(transaction);
- if (transaction != null) {
- changes.clear();
- changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId());
- Set transactions = new HashSet<>();
- transactions.add(transaction);
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- }
- }
-
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @Override
- public CommandProcessingResult approveShareAccount(Long accountId, JsonCommand jsonCommand) {
-
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndApprove(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.save(account);
- final String noteText = jsonCommand.stringValueOfParameterNamed("note");
- if (StringUtils.isNotBlank(noteText)) {
- final Note note = Note.shareNote(account, noteText);
- changes.put("note", noteText);
- this.noteRepository.save(note);
- }
- }
- Set transactions = account.getShareAccountTransactions();
- Set journalTransactions = new HashSet<>();
- Long totalSubsribedShares = Long.valueOf(0);
-
- for (ShareAccountTransaction transaction : transactions) {
- if (transaction.isActive() && transaction.isPurchasTransaction()) {
- journalTransactions.add(transaction);
- totalSubsribedShares += transaction.getTotalShares();
- }
- }
- ShareProduct shareProduct = account.getShareProduct();
- shareProduct.addSubscribedShares(totalSubsribedShares);
- this.shareProductRepository.save(shareProduct);
-
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions));
-
- businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account));
-
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @Override
- public CommandProcessingResult rejectShareAccount(Long accountId, JsonCommand jsonCommand) {
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndReject(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.save(account);
- final String noteText = jsonCommand.stringValueOfParameterNamed("note");
- if (StringUtils.isNotBlank(noteText)) {
- final Note note = Note.shareNote(account, noteText);
- changes.put("note", noteText);
- this.noteRepository.save(note);
- }
- }
- Set transactions = account.getShareAccountTransactions();
- Set journalTransactions = new HashSet<>();
- for (ShareAccountTransaction transaction : transactions) {
- if (transaction.isActive() && !transaction.isChargeTransaction()) {
- journalTransactions.add(transaction);
- }
- }
-
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions));
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @Override
- public CommandProcessingResult undoApproveShareAccount(Long accountId, JsonCommand jsonCommand) {
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndUndoApprove(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.save(account);
- final String noteText = jsonCommand.stringValueOfParameterNamed("note");
- if (StringUtils.isNotBlank(noteText)) {
- final Note note = Note.shareNote(account, noteText);
- changes.put("note", noteText);
- this.noteRepository.save(note);
- }
- }
-
- Set transactions = account.getShareAccountTransactions();
- ArrayList journalEntryTransactions = new ArrayList<>();
- for (ShareAccountTransaction transaction : transactions) {
- if (transaction.isActive() && !transaction.isChargeTransaction()) {
- journalEntryTransactions.add(transaction.getId());
- }
- }
- LocalDate transactionDate = DateUtils.getBusinessLocalDate();
- this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate);
- journalEntryWritePlatformService.createJournalEntriesForShares(
- populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @Override
- public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) {
-
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.save(account);
- }
-
- // Cashier collected cash and entered GL directly via the transaction entry
- // workstation (DR Cash → CR Share Sundry GL for share amount, and DR Cash → CR
- // Charges GL for charges). Mark charges as paid to reflect cashier's collection
- // without posting duplicate GL entries — the approval step already posted the
- // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
- jdbcTemplate.update(
- "UPDATE m_share_account_charge SET is_paid_derived = true WHERE account_id = ?",
- accountId);
-
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @SuppressWarnings("unchecked")
- @Override
- public CommandProcessingResult approveAdditionalShares(Long accountId, JsonCommand jsonCommand) {
-
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndApproveAddtionalShares(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.save(account);
- ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname);
- Long totalSubscribedShares = Long.valueOf(0);
- if (transactionIds != null) {
- Set transactions = new HashSet<>();
- for (Long id : transactionIds) {
- ShareAccountTransaction transaction = account.retrievePurchasedShares(id);
- transactions.add(transaction);
- totalSubscribedShares += transaction.getTotalShares();
- }
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- }
- if (!totalSubscribedShares.equals(Long.valueOf(0))) {
- ShareProduct shareProduct = account.getShareProduct();
- shareProduct.addSubscribedShares(totalSubscribedShares);
- this.shareProductRepository.save(shareProduct);
- }
- }
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @SuppressWarnings("unchecked")
- @Override
- public CommandProcessingResult rejectAdditionalShares(Long accountId, JsonCommand jsonCommand) {
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndRejectAddtionalShares(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.save(account);
- ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname);
- if (transactionIds != null) {
- Set transactions = new HashSet<>();
- for (Long id : transactionIds) {
- ShareAccountTransaction transaction = account.retrievePurchasedShares(id);
- transactions.add(transaction);
- }
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- }
- }
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @Override
- public CommandProcessingResult redeemShares(Long accountId, JsonCommand jsonCommand) {
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndRedeemShares(jsonCommand, account);
- if (!changes.isEmpty()) {
- this.shareAccountRepository.saveAndFlush(account);
- ShareAccountTransaction transaction = (ShareAccountTransaction) changes
- .get(ShareAccountApiConstants.requestedshares_paramname);
- // after saving, entity will have different object. So need to
- // retrieve the entity object
- transaction = account.getShareAccountTransaction(transaction);
- Long redeemShares = transaction.getTotalShares();
- ShareProduct shareProduct = account.getShareProduct();
- // remove the redeem shares from total subscribed shares
- shareProduct.removeSubscribedShares(redeemShares);
- this.shareProductRepository.saveAndFlush(shareProduct);
-
- Set transactions = new HashSet<>();
- transactions.add(transaction);
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- changes.clear();
- changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId());
-
- }
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- @Override
- public CommandProcessingResult closeShareAccount(Long accountId, JsonCommand jsonCommand) {
- try {
- ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
- Map changes = this.accountDataSerializer.validateAndClose(jsonCommand, account);
- // Account status/closedDate are applied regardless of whether there was anything left to
- // redeem — save and note-capture must not be gated on a transaction actually existing.
- this.shareAccountRepository.saveAndFlush(account);
- final String noteText = jsonCommand.stringValueOfParameterNamed("note");
- if (StringUtils.isNotBlank(noteText)) {
- final Note note = Note.shareNote(account, noteText);
- this.noteRepository.save(note);
- }
- // Only closures that actually redeemed remaining shares carry a transaction to post GL
- // entries for — an account already at zero shares has none, and posting a zero-value
- // transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL.
- final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname);
- if (transactionChange instanceof ShareAccountTransaction) {
- ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange);
- Set transactions = new HashSet<>();
- transactions.add(transaction);
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- changes.clear();
- changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId());
- }
- return new CommandProcessingResultBuilder() //
- .withCommandId(jsonCommand.commandId()) //
- .withEntityId(accountId) //
- .with(changes) //
- .build();
- } catch (final JpaSystemException | DataIntegrityViolationException dve) {
- handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
- return CommandProcessingResult.empty();
- }
- }
-
- private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dve) {
- throw ErrorHandler.getMappable(dve, "error.msg.shareaccount.unknown.data.integrity.issue",
- "Unknown data integrity issue with resource.");
- }
-}
+/**
+ * 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.shareaccounts.service;
+
+import jakarta.persistence.PersistenceException;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
+import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
+import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
+import org.apache.fineract.infrastructure.accountnumberformat.domain.EntityAccountType;
+import org.apache.fineract.infrastructure.core.api.JsonCommand;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
+import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountApproveBusinessEvent;
+import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountCreateBusinessEvent;
+import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
+import org.apache.fineract.portfolio.account.service.AccountNumberGenerator;
+import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants;
+import org.apache.fineract.portfolio.note.domain.Note;
+import org.apache.fineract.portfolio.note.domain.NoteRepository;
+import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
+import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
+import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy;
+import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper;
+import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction;
+import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer;
+import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
+import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.orm.jpa.JpaSystemException;
+
+@RequiredArgsConstructor
+public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareAccountWritePlatformService {
+
+ private final ShareAccountDataSerializer accountDataSerializer;
+
+ private final ShareAccountRepositoryWrapper shareAccountRepository;
+
+ private final ShareProductRepositoryWrapper shareProductRepository;
+
+ private final AccountNumberGenerator accountNumberGenerator;
+
+ private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
+
+ private final JournalEntryWritePlatformService journalEntryWritePlatformService;
+
+ private final NoteRepository noteRepository;
+
+
+ private final BusinessEventNotifierService businessEventNotifierService;
+
+ @Override
+ public CommandProcessingResult createShareAccount(JsonCommand jsonCommand) {
+ try {
+ ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand);
+ this.shareAccountRepository.saveAndFlush(account);
+ generateAccountNumber(account);
+ // GL entries are intentionally NOT posted at submission time.
+ // Cashier collects cash and enters GL directly in the transaction entry
+ // workstation: DR Cash → CR Share Sundry GL (share amount) and
+ // DR Cash → CR Charges GL (charge amount). The approval step posts the
+ // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
+
+ businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account));
+
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(account.getId()) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ } catch (final PersistenceException dve) {
+ Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
+ handleDataIntegrityIssues(jsonCommand, throwable, dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ private void generateAccountNumber(final ShareAccount account) {
+ if (account.isAccountNumberRequiresAutoGeneration()) {
+ final AccountNumberFormat accountNumberFormat = this.accountNumberFormatRepository.findByAccountType(EntityAccountType.SHARES);
+ account.updateAccountNumber(this.accountNumberGenerator.generate(account, accountNumberFormat));
+ this.shareAccountRepository.save(account);
+ }
+ }
+
+ private Map populateJournalEntries(final ShareAccount account, final Set transactions) {
+ final Map accountingBridgeData = new HashMap<>();
+ Boolean cashBasedAccounting = account.getShareProduct().getAccountingType() == 2;
+ accountingBridgeData.put("cashBasedAccountingEnabled", cashBasedAccounting);
+ accountingBridgeData.put("accrualBasedAccountingEnabled", Boolean.FALSE);
+ accountingBridgeData.put("shareAccountId", account.getId());
+ accountingBridgeData.put("shareProductId", account.getShareProduct().getId());
+ accountingBridgeData.put("officeId", account.getOfficeId());
+ accountingBridgeData.put("currencyCode", account.getCurrency().getCode());
+ final List> newTransactionsMap = new ArrayList<>();
+ accountingBridgeData.put("newTransactions", newTransactionsMap);
+
+ for (ShareAccountTransaction transaction : transactions) {
+ final Map transactionDto = new HashMap<>();
+ transactionDto.put("officeId", account.getOfficeId());
+ transactionDto.put("id", transaction.getId());
+ transactionDto.put("date", transaction.getPurchasedDate());
+ final Integer status = transaction.getTransactionStatus();
+ final ShareAccountTransactionEnumData statusEnum = new ShareAccountTransactionEnumData(status.longValue(), null, null);
+ final Integer type = transaction.getTransactionType();
+ final ShareAccountTransactionEnumData typeEnum = new ShareAccountTransactionEnumData(type.longValue(), null, null);
+ transactionDto.put("status", statusEnum);
+ transactionDto.put("type", typeEnum);
+ if (transaction.isPurchaseRejectedTransaction() || transaction.isRedeemTransaction()) {
+ BigDecimal amount = transaction.amount();
+ if (transaction.chargeAmount() != null) {
+ amount = amount.add(transaction.chargeAmount());
+ }
+ transactionDto.put("amount", amount);
+ } else {
+ transactionDto.put("amount", transaction.amount());
+ }
+
+ transactionDto.put("chargeAmount", transaction.chargeAmount());
+ transactionDto.put("paymentTypeId", null); // FIXME::make it cash
+ // payment
+ if (transaction.getChargesPaidBy() != null && !transaction.getChargesPaidBy().isEmpty()) {
+ final List> chargesPaidData = new ArrayList<>();
+ transactionDto.put("chargesPaid", chargesPaidData);
+ Set chargesPaidBySet = transaction.getChargesPaidBy();
+ for (ShareAccountChargePaidBy chargesPaidBy : chargesPaidBySet) {
+ Map chargesPaidDto = new HashMap<>();
+ chargesPaidDto.put("chargeId", chargesPaidBy.getChargeId());
+ chargesPaidDto.put("sharesChargeId", chargesPaidBy.getShareChargeId());
+ chargesPaidDto.put("amount", chargesPaidBy.getAmount());
+ chargesPaidData.add(chargesPaidDto);
+ }
+ }
+ newTransactionsMap.add(transactionDto);
+ }
+ return accountingBridgeData;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public CommandProcessingResult updateShareAccount(Long accountId, JsonCommand jsonCommand) {
+ try {
+ LocalDate transactionDate = DateUtils.getBusinessLocalDate();
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndUpdate(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.save(account);
+ }
+ // since we are reverting all journal entries we need to add journal
+ // entries for application request
+ if (changes.containsKey("reversalIds")) {
+ ArrayList reversalIds = (ArrayList) changes.get("reversalIds");
+ this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate);
+ journalEntryWritePlatformService.createJournalEntriesForShares(
+ populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
+ changes.remove("reversalIds");
+ }
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ } catch (final PersistenceException dve) {
+ Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
+ handleDataIntegrityIssues(jsonCommand, throwable, dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @Override
+ public CommandProcessingResult applyAddtionalShares(final Long accountId, JsonCommand jsonCommand) {
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndApplyAddtionalShares(jsonCommand, account);
+ ShareAccountTransaction transaction = null;
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.saveAndFlush(account);
+ transaction = (ShareAccountTransaction) changes.get(ShareAccountApiConstants.additionalshares_paramname);
+ transaction = account.getShareAccountTransaction(transaction);
+ if (transaction != null) {
+ changes.clear();
+ changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId());
+ Set transactions = new HashSet<>();
+ transactions.add(transaction);
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
+ }
+ }
+
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @Override
+ public CommandProcessingResult approveShareAccount(Long accountId, JsonCommand jsonCommand) {
+
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndApprove(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.save(account);
+ final String noteText = jsonCommand.stringValueOfParameterNamed("note");
+ if (StringUtils.isNotBlank(noteText)) {
+ final Note note = Note.shareNote(account, noteText);
+ changes.put("note", noteText);
+ this.noteRepository.save(note);
+ }
+ }
+ Set transactions = account.getShareAccountTransactions();
+ Set journalTransactions = new HashSet<>();
+ Long totalSubsribedShares = Long.valueOf(0);
+
+ for (ShareAccountTransaction transaction : transactions) {
+ if (transaction.isActive() && transaction.isPurchasTransaction()) {
+ journalTransactions.add(transaction);
+ totalSubsribedShares += transaction.getTotalShares();
+ }
+ }
+ ShareProduct shareProduct = account.getShareProduct();
+ shareProduct.addSubscribedShares(totalSubsribedShares);
+ this.shareProductRepository.save(shareProduct);
+
+ // No journal entries here. Approving an application decides the member may hold the
+ // shares; it does not collect anything from them. Posting Dr Share Sundry / Cr Share
+ // Capital at this point issues share capital against a Sundry balance that holds no
+ // receipt for it — in effect funding one member's shares out of money other members
+ // paid in. The entries belong to settlement, where the money actually arrives: either
+ // applying the member's cash receipt (ShareAdvanceCollectionApiResource) or debiting
+ // their savings (ShareSavingsDebitRequestApiResource).
+ businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account));
+
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @Override
+ public CommandProcessingResult rejectShareAccount(Long accountId, JsonCommand jsonCommand) {
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndReject(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.save(account);
+ final String noteText = jsonCommand.stringValueOfParameterNamed("note");
+ if (StringUtils.isNotBlank(noteText)) {
+ final Note note = Note.shareNote(account, noteText);
+ changes.put("note", noteText);
+ this.noteRepository.save(note);
+ }
+ }
+ Set transactions = account.getShareAccountTransactions();
+ Set journalTransactions = new HashSet<>();
+ for (ShareAccountTransaction transaction : transactions) {
+ if (transaction.isActive() && !transaction.isChargeTransaction()) {
+ journalTransactions.add(transaction);
+ }
+ }
+
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions));
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @Override
+ public CommandProcessingResult undoApproveShareAccount(Long accountId, JsonCommand jsonCommand) {
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndUndoApprove(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.save(account);
+ final String noteText = jsonCommand.stringValueOfParameterNamed("note");
+ if (StringUtils.isNotBlank(noteText)) {
+ final Note note = Note.shareNote(account, noteText);
+ changes.put("note", noteText);
+ this.noteRepository.save(note);
+ }
+ }
+
+ Set transactions = account.getShareAccountTransactions();
+ ArrayList journalEntryTransactions = new ArrayList<>();
+ for (ShareAccountTransaction transaction : transactions) {
+ if (transaction.isActive() && !transaction.isChargeTransaction()) {
+ journalEntryTransactions.add(transaction.getId());
+ }
+ }
+ LocalDate transactionDate = DateUtils.getBusinessLocalDate();
+ this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate);
+ journalEntryWritePlatformService.createJournalEntriesForShares(
+ populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @Override
+ public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) {
+
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.save(account);
+ }
+
+ // Charges are deliberately NOT marked paid here. Attaching an entry fee when the
+ // account is created records what the member owes; activating the account decides they
+ // may hold shares. Neither collects anything, and nobody has yet said where the money
+ // is coming from — a cash receipt at the counter, or a debit from the member's savings.
+ //
+ // Marking it paid at activation left the fee showing as settled while it was still
+ // outstanding, so the settlement step then asked the member for the share value alone
+ // and the fee was never collected at all. It is marked paid where the money actually
+ // arrives: ShareAdvanceCollectionApiResource (cash receipt) or
+ // ShareSavingsDebitRequestApiResource (savings debit), both of which set
+ // amount_paid_derived and amount_outstanding_derived alongside the flag.
+
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public CommandProcessingResult approveAdditionalShares(Long accountId, JsonCommand jsonCommand) {
+
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndApproveAddtionalShares(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.save(account);
+ ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname);
+ Long totalSubscribedShares = Long.valueOf(0);
+ if (transactionIds != null) {
+ Set transactions = new HashSet<>();
+ for (Long id : transactionIds) {
+ ShareAccountTransaction transaction = account.retrievePurchasedShares(id);
+ transactions.add(transaction);
+ totalSubscribedShares += transaction.getTotalShares();
+ }
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
+ }
+ if (!totalSubscribedShares.equals(Long.valueOf(0))) {
+ ShareProduct shareProduct = account.getShareProduct();
+ shareProduct.addSubscribedShares(totalSubscribedShares);
+ this.shareProductRepository.save(shareProduct);
+ }
+ }
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public CommandProcessingResult rejectAdditionalShares(Long accountId, JsonCommand jsonCommand) {
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndRejectAddtionalShares(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.save(account);
+ ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname);
+ if (transactionIds != null) {
+ Set transactions = new HashSet<>();
+ for (Long id : transactionIds) {
+ ShareAccountTransaction transaction = account.retrievePurchasedShares(id);
+ transactions.add(transaction);
+ }
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
+ }
+ }
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @Override
+ public CommandProcessingResult redeemShares(Long accountId, JsonCommand jsonCommand) {
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndRedeemShares(jsonCommand, account);
+ if (!changes.isEmpty()) {
+ this.shareAccountRepository.saveAndFlush(account);
+ ShareAccountTransaction transaction = (ShareAccountTransaction) changes
+ .get(ShareAccountApiConstants.requestedshares_paramname);
+ // after saving, entity will have different object. So need to
+ // retrieve the entity object
+ transaction = account.getShareAccountTransaction(transaction);
+ Long redeemShares = transaction.getTotalShares();
+ ShareProduct shareProduct = account.getShareProduct();
+ // remove the redeem shares from total subscribed shares
+ shareProduct.removeSubscribedShares(redeemShares);
+ this.shareProductRepository.saveAndFlush(shareProduct);
+
+ Set transactions = new HashSet<>();
+ transactions.add(transaction);
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
+ changes.clear();
+ changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId());
+
+ }
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ @Override
+ public CommandProcessingResult closeShareAccount(Long accountId, JsonCommand jsonCommand) {
+ try {
+ ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
+ Map changes = this.accountDataSerializer.validateAndClose(jsonCommand, account);
+ // Account status/closedDate are applied regardless of whether there was anything left to
+ // redeem — save and note-capture must not be gated on a transaction actually existing.
+ this.shareAccountRepository.saveAndFlush(account);
+ final String noteText = jsonCommand.stringValueOfParameterNamed("note");
+ if (StringUtils.isNotBlank(noteText)) {
+ final Note note = Note.shareNote(account, noteText);
+ this.noteRepository.save(note);
+ }
+ // Only closures that actually redeemed remaining shares carry a transaction to post GL
+ // entries for — an account already at zero shares has none, and posting a zero-value
+ // transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL.
+ final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname);
+ if (transactionChange instanceof ShareAccountTransaction) {
+ ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange);
+ Set transactions = new HashSet<>();
+ transactions.add(transaction);
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
+ changes.clear();
+ changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId());
+ }
+ return new CommandProcessingResultBuilder() //
+ .withCommandId(jsonCommand.commandId()) //
+ .withEntityId(accountId) //
+ .with(changes) //
+ .build();
+ } catch (final JpaSystemException | DataIntegrityViolationException dve) {
+ handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve);
+ return CommandProcessingResult.empty();
+ }
+ }
+
+ private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dve) {
+ throw ErrorHandler.getMappable(dve, "error.msg.shareaccount.unknown.data.integrity.issue",
+ "Unknown data integrity issue with resource.");
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java
index eed5904cc..3d5aa3f2f 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java
@@ -125,11 +125,10 @@ public class ShareAccountsConfiguration {
ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository,
AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository,
- org.springframework.jdbc.core.JdbcTemplate jdbcTemplate,
BusinessEventNotifierService businessEventNotifierService) {
return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository,
accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository,
- jdbcTemplate, businessEventNotifierService);
+ businessEventNotifierService);
}
@Bean
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java
new file mode 100644
index 000000000..6f84b86ec
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java
@@ -0,0 +1,579 @@
+/**
+ * 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.shareproducts.api;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+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 org.apache.fineract.commands.domain.CommandWrapper;
+import org.apache.fineract.commands.service.CommandWrapperBuilder;
+import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * Declares one dividend across every share product at once.
+ *
+ * A society's board resolves a single rate for a financial year — it does not resolve one for each share product — but
+ * Fineract only declares dividends one product at a time. Doing that by hand means repeating the same decision per
+ * product and hoping the rate and period match, when a mismatch would quietly pay two classes of member at different
+ * rates out of one pool.
+ *
+ * Each product still gets its own declaration row, because share capital lives in a separate GL per product
+ * (7.1 LINK SHARE, 7.2 SHARE) and the per-member allocation runs per product. What is shared is the decision: one
+ * rate, one period, applied to every product that has members holding shares in it.
+ *
+ * The declarations are created through the existing per-product command, so the whole downstream chain — statutory
+ * appropriations, rounding, the residue sweep, the ceiling check against distributable profit — behaves exactly as it
+ * does for a single-product declaration.
+ */
+@Path("/v1/shareproducts/dividends")
+@Component
+@Tag(name = "All Products Dividend", description = "Declare one dividend across every share product for a financial year.")
+@RequiredArgsConstructor
+@Produces({ MediaType.APPLICATION_JSON })
+public class AllProductsDividendApiResource {
+
+ private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
+ private static final int DIVIDEND_STATUS_INITIATED = 100;
+
+ /** The account a dividend is declared against, once year-end closing has landed the profit in it. */
+ private static final String PROFIT_GL_CODE = "BANKPROFITND01";
+ private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage";
+ private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage";
+
+ /** Every member receives the declared rate on their own capital — what an AGM resolution promises. */
+ private static final String BASIS_RATE_ON_CAPITAL = "RATE_ON_CAPITAL";
+ /** The pool is a share of distributable profit, split across members afterwards. */
+ private static final String BASIS_PERCENT_OF_PROFIT = "PERCENT_OF_PROFIT";
+
+ private final PlatformSecurityContext context;
+ private final JdbcTemplate jdbcTemplate;
+ private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
+ private final Gson gson = new GsonBuilder().serializeNulls().create();
+
+ // ── GET /v1/shareproducts/dividends ── Every declaration, across products ──
+
+ @GET
+ public Response list() {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
+
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT d.id, d.product_id, p.name AS product_name, d.amount,
+ d.dividend_period_start_date, d.dividend_period_end_date, d.status, d.created_date,
+ (SELECT COUNT(*) FROM m_share_account_dividend_details x
+ WHERE x.dividend_pay_out_id = d.id AND x.amount > 0) AS member_count
+ FROM m_share_product_dividend_pay_out d
+ JOIN m_share_product p ON p.id = d.product_id
+ ORDER BY d.dividend_period_end_date DESC, d.product_id
+ """);
+
+ final List> entries = new ArrayList<>();
+ for (final Map row : rows) {
+ final Map entry = new LinkedHashMap<>();
+ entry.put("dividendId", row.get("id"));
+ entry.put("productId", row.get("product_id"));
+ entry.put("productName", row.get("product_name"));
+ entry.put("amount", row.get("amount"));
+ entry.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
+ entry.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
+ entry.put("financialYear", financialYearOf(String.valueOf(row.get("dividend_period_end_date"))));
+ entry.put("approved", ((Number) row.get("status")).intValue() != DIVIDEND_STATUS_INITIATED);
+ entry.put("memberCount", row.get("member_count"));
+ entry.put("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date")));
+ entries.add(entry);
+ }
+ return Response.ok(gson.toJson(entries)).build();
+ }
+
+ // ── GET /v1/shareproducts/dividends/preview ── What a rate costs, per product ──
+
+ /**
+ * What declaring this rate would cost, product by product, before anything is created.
+ *
+ * A rate is a promise about each member's own paid-up capital, so the amount it costs depends on how much capital
+ * each product's members held and for how long — which differs per product. Showing that split before the officer
+ * commits is what makes a single cross-product decision safe to take.
+ *
+ * A product with no eligible members is reported with a zero and a reason rather than omitted, so it is clear it
+ * was considered and skipped rather than overlooked.
+ */
+ @GET
+ @Path("/preview")
+ public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
+ @QueryParam("ratePercentage") final BigDecimal ratePercentage,
+ @QueryParam("dividendBasis") final String dividendBasis) {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
+
+ if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
+ throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
+ }
+ final LocalDate from = parseDate(fromDate);
+ final LocalDate to = parseDate(toDate);
+ if (to.isBefore(from)) {
+ throw apiError(400, "toDate must not be before fromDate");
+ }
+ final String basis = normaliseBasis(dividendBasis);
+
+ final List capitals = capitalPerProduct(from, to);
+ final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ final ProfitWaterfall waterfall = profitWaterfall(from, to);
+
+ // Under PERCENT_OF_PROFIT the pool is sized once from profit and then split between products in
+ // proportion to the capital each carries — a share of profit is a society-wide figure, so it
+ // cannot be computed per product the way a rate on capital can.
+ final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) && ratePercentage != null
+ ? percentageOf(waterfall.distributableProfit, ratePercentage)
+ : null;
+
+ final List> products = new ArrayList<>();
+ BigDecimal grandTotal = BigDecimal.ZERO;
+
+ for (final ProductCapital capital : capitals) {
+ final BigDecimal amount = amountForProduct(basis, ratePercentage, capital.weightedCapital, totalWeightedCapital,
+ profitPool);
+
+ final Map entry = new LinkedHashMap<>();
+ entry.put("productId", capital.productId);
+ entry.put("productName", capital.productName);
+ entry.put("weightedCapital", capital.weightedCapital);
+ entry.put("amount", amount);
+ entry.put("eligible", capital.weightedCapital.signum() > 0);
+ entry.put("reason", capital.weightedCapital.signum() > 0 ? null
+ : "No member has held shares in this product long enough to qualify for this period.");
+ entry.put("alreadyDeclared", hasDeclarationFor(capital.productId, from, to));
+ products.add(entry);
+ grandTotal = grandTotal.add(amount);
+ }
+
+ final Map out = new LinkedHashMap<>();
+ out.put("fromDate", fromDate);
+ out.put("toDate", toDate);
+ out.put("financialYear", financialYearOf(toDate));
+ out.put("dividendBasis", basis);
+ out.put("ratePercentage", ratePercentage);
+ out.put("totalWeightedCapital", totalWeightedCapital);
+ out.put("profitGlCode", PROFIT_GL_CODE);
+ // The whole waterfall, so the screen can show where the declarable figure came from rather
+ // than presenting it as a number the officer has to take on trust.
+ out.putAll(waterfall.asMap());
+ // What is left in the profit account after this declaration — the amount that will be swept
+ // to Bank Profit on approval, and which no later year's dividend can draw on.
+ out.put("undistributedRemainder", waterfall.distributableProfit.subtract(grandTotal).max(BigDecimal.ZERO));
+ out.put("exceedsDistributableProfit", grandTotal.compareTo(waterfall.distributableProfit) > 0);
+ out.put("totalAmount", grandTotal);
+ out.put("products", products);
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ── POST /v1/shareproducts/dividends ── Declare across every product ──
+
+ /**
+ * Creates one declaration per eligible product, all for the same period and rate.
+ *
+ * Products with no eligible members, and products that already carry a declaration overlapping this period, are
+ * skipped and reported rather than failing the whole request — one product being ineligible is not a reason to
+ * leave the rest undeclared.
+ */
+ @POST
+ @Consumes({ MediaType.APPLICATION_JSON })
+ public Response declare(final String body) {
+ this.context.authenticatedUser();
+
+ final JsonObject json = parseBody(body);
+ final String fromDate = getString(json, "dividendPeriodStartDate");
+ final String toDate = getString(json, "dividendPeriodEndDate");
+ if (fromDate == null || toDate == null) {
+ throw apiError(400, "dividendPeriodStartDate and dividendPeriodEndDate (yyyy-MM-dd) are both required");
+ }
+ final LocalDate from = parseDate(fromDate);
+ final LocalDate to = parseDate(toDate);
+ final String basis = normaliseBasis(getString(json, "dividendBasis"));
+
+ final String rateText = getString(json, "ratePercentage");
+ if (rateText == null) {
+ throw apiError(400, "ratePercentage is required");
+ }
+ final BigDecimal rate = new BigDecimal(rateText);
+ if (rate.signum() <= 0) {
+ throw apiError(400, "ratePercentage must be greater than zero");
+ }
+
+ final List capitals = capitalPerProduct(from, to);
+ final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add);
+ final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis)
+ ? percentageOf(profitWaterfall(from, to).distributableProfit, rate)
+ : null;
+
+ final List> created = new ArrayList<>();
+ final List> skipped = new ArrayList<>();
+
+ for (final ProductCapital capital : capitals) {
+ final Long productId = capital.productId;
+ final String productName = capital.productName;
+
+ if (hasDeclarationFor(productId, from, to)) {
+ skipped.add(skipRow(productId, productName, "A dividend is already declared for this product over this period."));
+ continue;
+ }
+
+ if (capital.weightedCapital.signum() <= 0) {
+ skipped.add(skipRow(productId, productName,
+ "No member has held shares in this product long enough to qualify for this period."));
+ continue;
+ }
+
+ final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool);
+ if (amount.signum() <= 0) {
+ skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product."));
+ continue;
+ }
+
+ // Routed through the existing per-product command so the whole downstream chain —
+ // appropriations, rounding, residue sweep, ceiling check — runs exactly as it does for a
+ // single-product declaration, rather than being reimplemented here.
+ final JsonObject payload = new JsonObject();
+ payload.addProperty("locale", "en");
+ payload.addProperty("dateFormat", "yyyy-MM-dd");
+ payload.addProperty("dividendPeriodStartDate", fromDate);
+ payload.addProperty("dividendPeriodEndDate", toDate);
+ payload.addProperty("dividendAmount", amount);
+
+ try {
+ final CommandWrapper command = new CommandWrapperBuilder().createShareProductDividendPayoutCommand(productId)
+ .withJson(payload.toString()).build();
+ final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command);
+
+ final Map row = new LinkedHashMap<>();
+ row.put("productId", productId);
+ row.put("productName", productName);
+ row.put("amount", amount);
+ row.put("dividendId", result.getSubResourceId());
+ created.add(row);
+ } catch (final RuntimeException e) {
+ // Most often the distributable-profit ceiling: an earlier product in this same run has
+ // already consumed the pool. Reported per product so the officer can see which ones
+ // went through and why the rest did not, instead of losing all of them to one failure.
+ skipped.add(skipRow(productId, productName,
+ e.getMessage() == null ? "Could not declare for this product." : e.getMessage()));
+ }
+ }
+
+ if (created.isEmpty()) {
+ throw apiError(400, "No dividend could be declared. " + (skipped.isEmpty() ? "There are no share products."
+ : skipped.get(0).get("reason").toString()));
+ }
+
+ final Map out = new LinkedHashMap<>();
+ out.put("financialYear", financialYearOf(toDate));
+ out.put("declarationsCreated", created.size());
+ out.put("created", created);
+ out.put("skipped", skipped);
+ return Response.ok(gson.toJson(out)).build();
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────
+
+ /** One product's weighted paid-up capital for the period. */
+ private static final class ProductCapital {
+
+ private final Long productId;
+ private final String productName;
+ private final BigDecimal weightedCapital;
+
+ private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital) {
+ this.productId = productId;
+ this.productName = productName;
+ this.weightedCapital = weightedCapital;
+ }
+ }
+
+ /** Weighted paid-up capital for every share product, in one pass. */
+ private List capitalPerProduct(final LocalDate from, final LocalDate to) {
+ final List capitals = new ArrayList<>();
+ for (final Map product : jdbcTemplate
+ .queryForList("SELECT id, name, minimum_active_period_frequency FROM m_share_product ORDER BY id")) {
+ final Long productId = ((Number) product.get("id")).longValue();
+ final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0
+ : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue();
+ final BigDecimal capitalDays = weightedCapitalDays(productId, from, to, minimumActivePeriod);
+ capitals.add(new ProductCapital(productId, (String) product.get("name"),
+ capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP)));
+ }
+ return capitals;
+ }
+
+ /**
+ * What one product's declaration comes to, under whichever basis the officer chose.
+ *
+ * RATE_ON_CAPITAL is what a rate means in an AGM resolution: every member receives that rate on their own capital,
+ * so each product's amount follows directly from the capital its members hold.
+ *
+ * PERCENT_OF_PROFIT sizes a single pool from the society's distributable profit, which is not a per-product figure
+ * at all. Splitting it in proportion to each product's weighted capital is what keeps a rupee of capital worth the
+ * same across products — any other split would pay two members with identical holdings different amounts purely
+ * because they hold different share classes.
+ */
+ private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital,
+ final BigDecimal totalWeightedCapital, final BigDecimal profitPool) {
+ if (ratePercentage == null || weightedCapital.signum() <= 0) {
+ return BigDecimal.ZERO;
+ }
+ if (BASIS_PERCENT_OF_PROFIT.equals(basis)) {
+ if (profitPool == null || totalWeightedCapital.signum() <= 0) {
+ return BigDecimal.ZERO;
+ }
+ return profitPool.multiply(weightedCapital).divide(totalWeightedCapital, 2, RoundingMode.HALF_UP);
+ }
+ return percentageOf(weightedCapital, ratePercentage);
+ }
+
+ private BigDecimal percentageOf(final BigDecimal base, final BigDecimal percentage) {
+ return base.multiply(percentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+ }
+
+ private String normaliseBasis(final String basis) {
+ return BASIS_PERCENT_OF_PROFIT.equalsIgnoreCase(basis) ? BASIS_PERCENT_OF_PROFIT : BASIS_RATE_ON_CAPITAL;
+ }
+
+ /**
+ * The appropriation waterfall for a period: net profit, what the statutory reserve and education fund take out of
+ * it, and what is left for a dividend.
+ *
+ * Carried as a whole rather than collapsed to the final figure because the officer is approving a division of the
+ * society's profit, not a single number. Seeing ₹2,22,000 alone gives no way to tell a small profit apart from a
+ * large one that has already been heavily appropriated — and the reserve and education percentages are
+ * configurable, so the amounts they take cannot be inferred from the result either.
+ */
+ private static final class ProfitWaterfall {
+
+ private final BigDecimal netProfit;
+ private final BigDecimal statutoryReservePercentage;
+ private final BigDecimal statutoryReserveAmount;
+ private final BigDecimal educationFundPercentage;
+ private final BigDecimal educationFundAmount;
+ private final BigDecimal distributableProfit;
+
+ private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal statutoryReservePercentage,
+ final BigDecimal statutoryReserveAmount, final BigDecimal educationFundPercentage,
+ final BigDecimal educationFundAmount, final BigDecimal distributableProfit) {
+ this.netProfit = netProfit;
+ this.statutoryReservePercentage = statutoryReservePercentage;
+ this.statutoryReserveAmount = statutoryReserveAmount;
+ this.educationFundPercentage = educationFundPercentage;
+ this.educationFundAmount = educationFundAmount;
+ this.distributableProfit = distributableProfit;
+ }
+
+ private Map asMap() {
+ final Map out = new LinkedHashMap<>();
+ out.put("netProfit", netProfit);
+ out.put("statutoryReservePercentage", statutoryReservePercentage);
+ out.put("statutoryReserveAmount", statutoryReserveAmount);
+ out.put("educationFundPercentage", educationFundPercentage);
+ out.put("educationFundAmount", educationFundAmount);
+ out.put("distributableProfit", distributableProfit);
+ return out;
+ }
+ }
+
+ /**
+ * The profit a dividend may be declared out of: the year's net profit less the statutory reserve and education
+ * fund, which are appropriated before any dividend.
+ *
+ * Read from the Bank Profit (Non-Distributable) GL once the year has been closed, so the dividend can never
+ * disagree with the closed books. An unclosed year has nothing declarable, which is reported as zero rather than
+ * guessed at from a P&L that is still moving.
+ */
+ private ProfitWaterfall profitWaterfall(final LocalDate from, final LocalDate to) {
+ final BigDecimal netProfit = netProfitForPeriod(from, to);
+
+ final BigDecimal reservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE);
+ final BigDecimal educationPercentage = configuredPercentage(CONFIG_EDUCATION_FUND);
+
+ // Appropriations only ever reduce a profit. A loss-making year has nothing to appropriate and
+ // nothing to distribute, so the whole waterfall collapses to zero rather than producing a
+ // negative "reserve" that would read as money owed back to the society.
+ if (netProfit == null || netProfit.signum() <= 0) {
+ final BigDecimal zero = BigDecimal.ZERO.setScale(2);
+ return new ProfitWaterfall(netProfit == null ? zero : netProfit, reservePercentage, zero, educationPercentage, zero,
+ zero);
+ }
+
+ final BigDecimal reserve = percentageOf(netProfit, reservePercentage);
+ final BigDecimal educationFund = percentageOf(netProfit, educationPercentage);
+ return new ProfitWaterfall(netProfit, reservePercentage, reserve, educationPercentage, educationFund,
+ netProfit.subtract(reserve).subtract(educationFund).max(BigDecimal.ZERO));
+ }
+
+ /**
+ * The period's net profit, read exactly the way the declaration validator reads it.
+ *
+ * This deliberately mirrors {@code ShareProductWritePlatformServiceJpaRepositoryImpl.profitForPeriod} rather than
+ * reading the profit GL directly. A closed year's profit lives in the profit GL, but a year is only "closed" once
+ * it is registered in {@code m_income_profit_transfer} — and after closing, income-less-expense for the period is
+ * zero, because closing is what zeroed those GLs. Reading the GL without that check would show a healthy profit on
+ * the preview and then have the declaration rejected for having none, which is exactly the contradiction an
+ * officer cannot act on.
+ */
+ private BigDecimal netProfitForPeriod(final LocalDate from, final LocalDate to) {
+ final int fyStartYear = from.getMonthValue() >= 4 ? from.getYear() : from.getYear() - 1;
+ final String financialYear = fyStartYear + "-" + String.format("%02d", (fyStartYear + 1) % 100);
+ final LocalDate fyStart = LocalDate.of(fyStartYear, 4, 1);
+ final LocalDate fyEnd = LocalDate.of(fyStartYear + 1, 3, 31);
+
+ if (!from.isAfter(fyStart) && !to.isBefore(fyEnd)) {
+ final Integer closedCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
+ if (closedCount != null && closedCount > 0) {
+ final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
+ FROM acc_gl_journal_entry je
+ JOIN acc_gl_account a ON a.id = je.account_id
+ WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
+ """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
+ return closedProfit == null ? BigDecimal.ZERO : closedProfit;
+ }
+ }
+
+ // The year is still open, so the profit is whatever the P&L says right now.
+ final Map