diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java
index fff2ccc74..d32c6b0eb 100644
--- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java
+++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java
@@ -279,9 +279,15 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
+ // Optional, not mandatory like the three below: it names an account for cash physically
+ // received at the counter for a share purchase, which every register in this bank now
+ // books against the account the money actually reached instead. Left absent, a product
+ // simply carries no such mapping, and any code path that still needs one (a genuine
+ // over-the-counter collection) fails with a named "GL not mapped" error rather than
+ // silently assuming the till.
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
element);
- baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId).notNull()
+ baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId)
.integerGreaterThanZero();
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java
index b7314f693..c17857d76 100644
--- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java
+++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java
@@ -27,6 +27,7 @@ import org.apache.fineract.accounting.common.AccountingRuleType;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
+import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -166,9 +167,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
case NONE:
break;
case CASH_BASED:
- // asset
- mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
- CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
+ // asset -- the one optional mapping here, so unlike the three below it needs its own
+ // handling for the case the field is now absent (see mergeOrRemoveSharesReferenceMapping).
+ mergeOrRemoveSharesReferenceMapping(element, sharesProductId, changes);
// income
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
@@ -194,4 +195,35 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
}
+
+ /**
+ * Handles the one optional share-product mapping, Shares Reference, on an edit that does not change
+ * the accounting rule.
+ *
+ *
+ * The other three mappings (Suspense, Income From Fees, Equity) are mandatory, so
+ * {@code mergeProductToAccountMappingChanges} only ever needs to handle "present and possibly
+ * changed" for them -- a null value there cannot happen without failing validation first. Shares
+ * Reference can legitimately be absent, and the shared merge helper (used across loan, savings and
+ * share products alike) only handles "present": it silently leaves an existing mapping row alone
+ * when the incoming value is null, rather than clearing it. Editing this one field out of an
+ * existing product would otherwise leave the product still carrying its old GL underneath, invisible
+ * on the edit form but still resolvable by any code path that looks it up directly.
+ */
+ private void mergeOrRemoveSharesReferenceMapping(final JsonElement element, final Long sharesProductId,
+ final Map changes) {
+ final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
+ element);
+ if (shareReferenceId != null) {
+ mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
+ CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
+ return;
+ }
+ final ProductToGLAccountMapping existing = this.accountMappingRepository.findCoreProductToFinAccountMapping(sharesProductId,
+ PortfolioProductType.SHARES.getValue(), CashAccountsForShares.SHARES_REFERENCE.getValue());
+ if (existing != null) {
+ this.accountMappingRepository.delete(existing);
+ changes.put(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), null);
+ }
+ }
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java
index aaa9143aa..2c6e8888c 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java
@@ -1468,6 +1468,13 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
+ // Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can
+ // now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so
+ // this has to fail with a clean, named error instead of NPEing on accountMapping.
+ if (accountMapping == null) {
+ throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId,
+ CashAccountsForShares.fromInt(accountMappingTypeId).toString());
+ }
glAccount = accountMapping.getGlAccount();
}
return glAccount;
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java
index 195582cf4..93f8d9e80 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java
@@ -631,7 +631,12 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final boolean cashBasedAccountingEnabled = (Boolean) accountingBridgeData.get("cashBasedAccountingEnabled");
final boolean accrualBasedAccountingEnabled = (Boolean) accountingBridgeData.get("accrualBasedAccountingEnabled");
- if (cashBasedAccountingEnabled) {
+ // A register that knows the transaction's real counterparty posts the entry itself, in one
+ // balanced pair against that account, and says so by wrapping the command in
+ // ShareAccountingSuppression.withOwnShareEntries(). Posting here as well would add a second
+ // entry for the same money, booked against the product's fixed Shares Reference account --
+ // which is what that register is avoiding in the first place.
+ if (cashBasedAccountingEnabled && ShareAccountingSuppression.shouldPostShareEntries()) {
final SharesDTO sharesDTO = this.helper.populateSharesDtoFromMap(accountingBridgeData, cashBasedAccountingEnabled,
accrualBasedAccountingEnabled);
final AccountingProcessorForShares accountingProcessorForShares = this.accountingProcessorForSharesFactory
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTransactionId.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTransactionId.java
new file mode 100644
index 000000000..f07414670
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTransactionId.java
@@ -0,0 +1,157 @@
+/**
+ * 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.journalentry.service;
+
+import java.sql.Date;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * Mints the transaction id that ties the rows of one posting together in acc_gl_journal_entry.
+ *
+ *
+ * Every feature here that posts a journal entry by direct JDBC used to invent this id by hand, and the two
+ * shapes that emerged were each wrong in their own way. Some concatenated a business row's own primary key
+ * ({@code "DIVROUND" + dividendPayOutId}); a primary key is only unique while its sequence keeps climbing,
+ * so a reset hands the same number out twice - which is exactly how two unrelated share withdrawals came to
+ * share one transaction id and render as a single doubled voucher. Others concatenated
+ * {@code System.nanoTime()}, which is unique but says nothing to whoever reads it off a screen
+ * ({@code "CASHMIG-1-20260913-923935527932300"}).
+ *
+ *
+ * This generator replaces both with one readable shape, {@code PREFIX-YYMMDD-NNNN}:
+ *
+ *
+ * SHWGL-260916-0001 the first share-withdrawal GL settlement posted on 16 Sep 2026
+ * DIVRND-260916-0007 the seventh dividend rounding posted that day
+ *
+ *
+ * The prefix names the event, the date is the tenant's business date, and the serial restarts at 0001 each
+ * day. That makes a transaction id self-describing: a reader can tell what it was and when it happened
+ * without looking anything up.
+ *
+ *
+ * Uniqueness does not depend on any business table. The serial comes from {@code m_ledger_txn_sequence}, a
+ * counter that belongs to no entity, so no entity's sequence can make it repeat. The counter is bumped by a
+ * single {@code INSERT ... ON CONFLICT DO UPDATE}, which takes and releases the row lock atomically, so two
+ * users posting at the same instant are serialised by the database rather than handed the same number - the
+ * same mechanism {@code m_voucher_sequence} already uses for voucher numbers.
+ *
+ *
+ * The counter alone would still repeat if that table were ever truncated or rewound by hand, which is
+ * precisely the accident being defended against, so the minted id is also checked against the ledger before
+ * being handed back and the counter is advanced past anything already in use. That makes a duplicate
+ * impossible by construction rather than by convention: the id is only returned once no row holds it.
+ *
+ *
+ * Ids already in the ledger keep their old shape. Nothing reconstructs a custom id to find its rows again
+ * (every call site mints one, posts with it, and never derives it a second time), so old and new shapes
+ * coexist without anything needing to read both.
+ *
+ *
+ * Postings made by Fineract's own command path are not covered here and keep their native
+ * {@code "S"/"L"/"SH"/"C" + transaction id} form, which {@code AccountingProcessorHelper} derives and
+ * several readers reconstruct. Those readers are hardened at the query instead, by resolving to the most
+ * recently posted rows rather than assuming the text is unique.
+ */
+public final class LedgerTransactionId {
+
+ /** Guards against an impossible-in-practice runaway if the ledger and the counter disagree. */
+ private static final int MAX_PROBES = 50;
+
+ private static final DateTimeFormatter YYMMDD = DateTimeFormatter.ofPattern("yyMMdd");
+
+ private LedgerTransactionId() {}
+
+ /**
+ * Returns the next transaction id for this kind of posting, e.g. {@code SHWGL-260916-0001}.
+ *
+ * @param prefix
+ * short uppercase code naming the event, from {@link LedgerTxnPrefix}
+ */
+ public static String next(final JdbcTemplate jdbcTemplate, final String prefix) {
+ // Tenant timezone, not the server's - the day the counter resets on has to be the branch's
+ // business day, otherwise a late-evening posting lands in the wrong series.
+ final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
+ final String datePart = today.format(YYMMDD);
+
+ for (int probe = 0; probe < MAX_PROBES; probe++) {
+ final int serial = nextSerial(jdbcTemplate, prefix, today);
+ final String candidate = String.format("%s-%s-%04d", prefix, datePart, serial);
+ if (!isTaken(jdbcTemplate, candidate)) {
+ return candidate;
+ }
+ // Only reachable if the counter was rewound behind the ledger by hand. Bumping again walks
+ // it forward until it clears whatever is already posted, which self-heals the counter.
+ }
+ throw new IllegalStateException("Could not mint a free ledger transaction id for prefix " + prefix + " after " + MAX_PROBES
+ + " attempts. The m_ledger_txn_sequence counter for this prefix is far behind the entries already posted today.");
+ }
+
+ /**
+ * Returns the id for a posting that has to be reproducible, e.g. {@code SAVINT-260916-P0002}.
+ *
+ *
+ * Some postings are not one-shot: a pooled interest run pays hundreds of members, and a run that
+ * stops halfway is resumed rather than restarted. Those resumed legs belong to the posting that
+ * started them, so the id has to come out the same on the second pass - a counter would hand the
+ * remainder a different id and split one posting across two transactions. This builds the id from
+ * what identifies the run instead: the same prefix, date and discriminator always produce the same
+ * string, and no counter is consumed.
+ *
+ *
+ * Uniqueness rests on the caller's own discriminator being unique for that prefix and date (the
+ * product being posted, say), which is what makes the id reproducible in the first place. Use
+ * {@link #next} for everything else - a posting that happens once wants a counter, not a formula.
+ * The {@code P} marks the tail as a discriminator so it is not misread as a serial.
+ *
+ * @param date
+ * the date the posting belongs to, which is not always today - interest can be posted
+ * for an earlier day
+ * @param discriminator
+ * what makes this run distinct from another on the same date, e.g. the product id
+ */
+ public static String forDatedSeries(final String prefix, final LocalDate date, final long discriminator) {
+ return String.format("%s-%s-P%04d", prefix, date.format(YYMMDD), discriminator);
+ }
+
+ /**
+ * Reserves the next counter value for one (prefix, day) series.
+ *
+ * The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use of the day and
+ * increments it thereafter. Being a single statement it is atomic, so no explicit lock is needed.
+ */
+ private static int nextSerial(final JdbcTemplate jdbcTemplate, final String prefix, final LocalDate day) {
+ return jdbcTemplate.queryForObject("""
+ INSERT INTO m_ledger_txn_sequence (prefix, series_date, last_number)
+ VALUES (?,?,1)
+ ON CONFLICT (prefix, series_date)
+ DO UPDATE SET last_number = m_ledger_txn_sequence.last_number + 1
+ RETURNING last_number
+ """, Integer.class, prefix, Date.valueOf(day));
+ }
+
+ private static boolean isTaken(final JdbcTemplate jdbcTemplate, final String transactionId) {
+ final Boolean taken = jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM acc_gl_journal_entry WHERE transaction_id = ?)",
+ Boolean.class, transactionId);
+ return Boolean.TRUE.equals(taken);
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java
new file mode 100644
index 000000000..d31549dbb
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java
@@ -0,0 +1,87 @@
+/**
+ * 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.journalentry.service;
+
+/**
+ * The event codes that open a transaction id minted by {@link LedgerTransactionId}.
+ *
+ *
+ * They live together in one place so a reader can decode any transaction id on a screen from a single list,
+ * and so no two features can drift into naming the same event differently - which had already happened:
+ * dividend forfeiture was posting under {@code DIVFORF} from one service and {@code DIVFORFEIT} from
+ * another, for the same event.
+ *
+ *
+ * Codes are uppercase, up to eight characters, and grouped by the module that raises them. A code is part of
+ * the audit trail once it has been posted, so treat these as append-only: add a new one for a new event
+ * rather than renaming an existing one, which would leave already-posted entries speaking a language nothing
+ * else uses any more.
+ */
+public final class LedgerTxnPrefix {
+
+ private LedgerTxnPrefix() {}
+
+ // A clerk's share withdrawal has no code here on purpose: it posts under the transaction id the
+ // native redeem command already minted ("SH" + share transaction id), because its debit leg is
+ // that command's own and the two have to group as one balanced entry.
+
+ // ── Share tranche funding ─────────────────────────────────────────────────
+ /** Additional share tranche: reversal of the stock reference posting. */
+ public static final String SHARE_TRANCHE_REFERENCE = "SHTREF";
+ /** Additional share tranche funded from the member's savings account. */
+ public static final String SHARE_TRANCHE_SAVINGS = "SHTSAV";
+
+ // ── Share collection and advances ─────────────────────────────────────────
+ /** Share application money collected. */
+ public static final String SHARE_COLLECTION = "SHCOL";
+ /** Share advance collected against a future allotment. */
+ public static final String SHARE_ADVANCE = "SHADV";
+ /** Share advance applied to an allotment. */
+ public static final String SHARE_ADVANCE_APPLIED = "SHAPP";
+
+ // ── Dividend ──────────────────────────────────────────────────────────────
+ /** Dividend claim paid out to a member. */
+ public static final String DIVIDEND_CLAIM = "DIVCLM";
+ /** Dividend declaration posted. */
+ public static final String DIVIDEND_DECLARED = "DIVDCL";
+ /** Dividend declaration approved. */
+ public static final String DIVIDEND_APPROVED = "DIVAPR";
+ /** Dividend payout closed off. */
+ public static final String DIVIDEND_CLOSED = "DIVCLS";
+ /** Rounding difference on a dividend payout. */
+ public static final String DIVIDEND_ROUNDING = "DIVRND";
+ /** Unclaimed dividend forfeited. */
+ public static final String DIVIDEND_FORFEIT = "DIVFRF";
+
+ // ── Branch day and cash ───────────────────────────────────────────────────
+ /** Opening cash migrated into the branch day register. */
+ public static final String CASH_MIGRATION = "CSHMIG";
+ /** Rounding difference on a clerk's day closure. */
+ public static final String CLOSURE_ROUNDING = "CLSRND";
+
+ // ── Year end ──────────────────────────────────────────────────────────────
+ /** Year-end transfer of income into the profit account. */
+ public static final String YEAR_END_PROFIT = "YEPFT";
+ /** Year-end closure of an income or expense account. */
+ public static final String YEAR_END_CLOSE = "YECLS";
+
+ // ── Savings ───────────────────────────────────────────────────────────────
+ /** Pooled savings interest posting for one product and date. */
+ public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/ShareAccountingSuppression.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/ShareAccountingSuppression.java
new file mode 100644
index 000000000..2efbd1239
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/ShareAccountingSuppression.java
@@ -0,0 +1,83 @@
+/**
+ * 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.journalentry.service;
+
+import java.util.function.Supplier;
+
+/**
+ * Lets a register that posts a share transaction's accounting itself tell the platform not to post its
+ * own.
+ *
+ *
Why this exists
+ *
+ *
+ * Fineract books every share transaction against a fixed "Shares Reference" account taken from the
+ * product's GL mapping: a redemption posts Dr Shares Equity / Cr Shares Reference, whatever the money
+ * actually did. That account is a placeholder for a counterparty the platform does not know, and in a
+ * real core banking ledger there is no such thing — a redemption paid in cash credits the cash
+ * account, and one settled by transfer credits the account it was transferred to. Naming a single
+ * asset account for both leaves every redemption sitting against a GL the money never touched, which
+ * then has to be corrected by hand.
+ *
+ *
+ * The governed clerk registers already know the real counterparty, because the clerk keyed it: the
+ * linked savings account, or the GL picked on the transfer's credit row. They post the redemption
+ * themselves, correctly, in one balanced entry. Before this flag existed they had to let the platform
+ * post its version first and then reverse the half that was wrong, which left a dead Cr Shares
+ * Reference row behind on every redemption and made the ledger read as though cash had moved.
+ *
+ *
Why a flag and not the product's accounting rule
+ *
+ *
+ * Turning the share product's accounting off entirely ({@code m_share_product.accounting_type = NONE})
+ * would silence the platform everywhere, including the share account screen's own Redeem action, which
+ * no register covers — those redemptions would post nothing at all and go unnoticed, because that
+ * branch fails silently by design. Scoping the suppression to the one call that is about to post its
+ * own entry leaves every other path exactly as it is.
+ *
+ *
+ * A ThreadLocal rather than a request attribute, and restoring the previous value rather than clearing
+ * it, for the same reasons as {@code VoucherService.withOwnVoucher} — a batch job has no request to
+ * hang the flag on, and nesting must not switch the suppression off early for an outer caller.
+ */
+public final class ShareAccountingSuppression {
+
+ private static final ThreadLocal CALLER_POSTS_SHARE_ENTRIES = ThreadLocal.withInitial(() -> Boolean.FALSE);
+
+ private ShareAccountingSuppression() {}
+
+ /**
+ * Runs {@code command} with the platform's own share journal entries suppressed, for a caller that
+ * posts the transaction's accounting itself.
+ */
+ public static T withOwnShareEntries(final Supplier command) {
+ final boolean previous = CALLER_POSTS_SHARE_ENTRIES.get();
+ CALLER_POSTS_SHARE_ENTRIES.set(Boolean.TRUE);
+ try {
+ return command.get();
+ } finally {
+ CALLER_POSTS_SHARE_ENTRIES.set(previous);
+ }
+ }
+
+ /** False only while a caller inside {@link #withOwnShareEntries} is posting its own entries. */
+ public static boolean shouldPostShareEntries() {
+ return !CALLER_POSTS_SHARE_ENTRIES.get();
+ }
+}
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
index 5bb6f385d..6873aa9eb 100644
--- 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
@@ -12,6 +12,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -117,7 +119,7 @@ public class IncomeProfitTransferService {
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 txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_PROFIT);
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
@@ -210,7 +212,7 @@ public class IncomeProfitTransferService {
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 txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_CLOSE);
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java
index 2bdc4ac2a..cbfc2ee8e 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java
@@ -38,6 +38,8 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -513,8 +515,7 @@ public class BranchDayRegisterApiResource {
final Long contraId = contraIds.get(0);
final String currencyCode = jdbcTemplate.queryForObject(
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
- final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-"
- + System.nanoTime();
+ final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CASH_MIGRATION);
insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser);
insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser);
@@ -1056,13 +1057,32 @@ public class BranchDayRegisterApiResource {
(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("""
+ final BigDecimal savingsDeposits = 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));
+ // Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
+ // it was missing from this total entirely, understating expected cash by exactly what a
+ // walk-in DD purchaser (or a member paying by cash rather than an account debit) had handed
+ // over that day. face_value + charges + GST is the fee calculation, which can land on a paisa
+ // fraction (e.g. 18% GST on a 40 rupee charge is 7.20); a customer paying cash can only tender
+ // whole rupees, so CEIL to the rupee actually collected -- rounding down would silently leave
+ // this same gap, just one rupee smaller. ACCOUNT_DEBIT-funded DDs move no physical cash and
+ // are correctly excluded by the payment method filter.
+ final BigDecimal ddCashReceipts = zero(jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(CEIL(dd.face_value + COALESCE(dd.charges_amount, 0) + COALESCE(dd.gst_amount, 0))), 0)
+ FROM m_sponsor_dd_issue_request dd
+ WHERE dd.status = 'APPROVED'
+ AND dd.payment_method = 'CASH'
+ AND dd.issued_date = ?
+ AND (dd.issuing_branch_id = ? OR dd.issuing_branch_id IS NULL)
+ """, BigDecimal.class, date, officeId));
+
+ final BigDecimal deposits = savingsDeposits.add(ddCashReceipts);
+
// A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java
index 8060a8192..7378801ec 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java
@@ -128,6 +128,12 @@ public class CashierDepositRequestApiResource {
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
+ // What the counter says this GL credit is actually for — a code value under the
+ // "GLEntryType" code (Admin > System > Manage Codes), defaulting to "General" unless the row
+ // is one of the flows below picks up its own money later (Share Advance Collection, Sponsor
+ // DD), in which case that adoption checks this tag so it can never take a voucher meant for
+ // the other one. See 0565_convert_entry_type_to_code_value.xml.
+ final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
// DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests.
// Persisted so the Sponsor DD issuance screen can read the breakdown back instead of
// retyping face value/charges once this request is approved and selected as cash evidence.
@@ -160,8 +166,8 @@ public class CashierDepositRequestApiResource {
client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
- dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -173,7 +179,7 @@ public class CashierDepositRequestApiResource {
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now()),
- ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount
+ ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
);
// Globally unique voucher number from the central m_voucher register
@@ -228,7 +234,7 @@ public class CashierDepositRequestApiResource {
+ " txn_date, status, cashier_id, cashier_name, cashier_username,"
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
+ " denomination_data, activity_log, submitted_at, actioned_at,"
- + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount"
+ + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id"
+ " FROM m_cashier_deposit_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -282,7 +288,7 @@ public class CashierDepositRequestApiResource {
payment_type_name, description, txn_date, status,
cashier_name, manager_name, manager_username, rejection_reason, txn_id,
submitted_at, actioned_at,
- dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
+ dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
FROM m_cashier_deposit_request
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()
@@ -624,7 +630,12 @@ public class CashierDepositRequestApiResource {
final Map gl = glAccountForVoucher(glAccountId);
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
} else {
- credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
+ // The request's own account_no is normally right, but if a cashier ever searched by a
+ // short/partial account number this is where that would surface -- prefer the number
+ // looked up fresh from savingsAccountId, the id actually posted against.
+ final String realAccountNo = savingsAccountNoOf((String) record.get("account_type"), savingsAccountId);
+ credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"),
+ realAccountNo != null ? realAccountNo : accountNo, amount);
}
voucherService.addLines(voucherNo, List.of(debit, credit));
@@ -634,6 +645,19 @@ public class CashierDepositRequestApiResource {
}
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
+ /** "CURRENT" accounts live in their own {@code m_current_account} table with their own id space --
+ * always querying {@code m_savings_account} would silently return an unrelated savings account's
+ * number whenever the two id spaces happened to collide. Every other non-GL type here is a
+ * {@code m_savings_account} row. */
+ private String savingsAccountNoOf(final String accountType, final Long accountId) {
+ if (accountId == null) {
+ return null;
+ }
+ final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
+ return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
+ .findFirst().orElse(null);
+ }
+
private Map glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
@@ -651,7 +675,7 @@ public class CashierDepositRequestApiResource {
txn_date, status, cashier_id, cashier_name, cashier_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
- dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
+ dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
FROM m_cashier_deposit_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -1014,6 +1038,18 @@ public class CashierDepositRequestApiResource {
}
}
+ /** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
+ * resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
+ private Long resolveEntryTypeCvId(final Long provided) {
+ if (provided != null) {
+ return provided;
+ }
+ return jdbcTemplate.queryForObject(
+ "SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
+ + " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
+ Long.class);
+ }
+
private String buildActivityLog(final String existingJson, final String action,
final String username, final String note) {
JsonArray log = new JsonArray();
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 ebad591bb..edde51cf2 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
@@ -499,86 +499,52 @@ public class CashReportApiResource {
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
- // The predicate goes through m_appuser because m_savings_txn_denomination records the acting
- // user in officer_id, not the m_cashiers row -- the same join the position query uses.
- final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
- + " SELECT au.id FROM m_appuser au "
- + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
+ // Today's own movement -- what actually crossed the counter, the vault hatch or the
+ // settlement window on this one date. Unchanged from before: this is informational, "what
+ // happened today", and stays that way regardless of what the drawer carried in from an
+ // earlier, unsettled day.
+ final Map> todayLegs = fetchLegTotals("= CAST(? AS DATE)", txnDate, scopedCashierId);
- // Every note and coin that reached the drawer or left it, per denomination.
- //
- // Four legs, not one. The counter breakdown alone answered "what did customers hand over",
- // which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
- // ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
- // the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
- // counted against. So the vault allocation and the settlement back are included on the same
- // footing as the counter work, and each row says where its notes came from.
- //
- // Cashier scoping differs per leg because each table records the party differently: the
- // counter tables name the acting user, the cash tables name the cashier row.
- final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
- final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
+ // What the drawer already held before today started -- everything ever recorded up to (but
+ // not including) this date, net. A cashier who force-closed yesterday without settling, then
+ // settled today, hands back notes that arrived on several different days; comparing that
+ // settlement only against today's own vault-in/deposit legs manufactured an impossible
+ // negative count for every denomination the earlier days had contributed. Cumulative "as of
+ // yesterday" is exactly the figure VaultPositionService/buildPositionForCashier already use
+ // for a drawer's running position, applied here as this report's opening balance.
+ final Map> openingLegs = fetchLegTotals("< CAST(? AS DATE)", txnDate, scopedCashierId);
- final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
- + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
- + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
- + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
- + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
- + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
- + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
- + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
- + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
- + "FROM ("
- // Counter work: what customers handed over and were paid.
- + " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
- + " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
- + " FROM m_savings_txn_denomination std "
- + " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
- + " UNION ALL "
- // Drawn from the vault in the morning.
- + " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
- + " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
- + " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
- + vaultCashierFilter
- + " UNION ALL "
- // Handed back to the vault at close.
- + " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
- + " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
- + " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
- + settleCashierFilter
- + ") legs "
- + "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
- + "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
- + "ORDER BY legs.denomination_value DESC, dm.kind";
-
- final List args = new ArrayList<>();
- args.add(txnDate);
- if (scopedCashierId != null) {
- args.add(scopedCashierId);
- }
- args.add(txnDate);
- if (scopedCashierId != null) {
- args.add(scopedCashierId);
- }
- args.add(txnDate);
- if (scopedCashierId != null) {
- args.add(scopedCashierId);
+ final Map> merged = new LinkedHashMap<>(todayLegs);
+ for (final Long denomId : openingLegs.keySet()) {
+ merged.computeIfAbsent(denomId, id -> emptyLegRow(openingLegs.get(id)));
}
- final List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
- final long vaultInCount = rs.getLong("vault_in_count");
- final long depositCount = rs.getLong("deposit_count");
- final long withdrawalCount = rs.getLong("withdrawal_count");
- final long settledOutCount = rs.getLong("settled_out_count");
- final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
- final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
- final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
- final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
+ final List> rows = new ArrayList<>();
+ for (final Map today : merged.values()) {
+ final Long denomId = (Long) today.get("denominationId");
+ final Map opening = openingLegs.getOrDefault(denomId, null);
+ final long openingCount = opening == null ? 0L
+ : (long) opening.get("vaultInCount") + (long) opening.get("depositCount")
+ - (long) opening.get("withdrawalCount") - (long) opening.get("settledOutCount");
+ final BigDecimal openingAmount = opening == null ? BigDecimal.ZERO
+ : ((BigDecimal) opening.get("vaultInAmount")).add((BigDecimal) opening.get("depositAmount"))
+ .subtract((BigDecimal) opening.get("withdrawalAmount")).subtract((BigDecimal) opening.get("settledOutAmount"));
- Map row = new LinkedHashMap<>();
- row.put("denominationId", rs.getLong("denomination_id"));
- row.put("denominationValue", rs.getInt("denomination_value"));
- row.put("kind", rs.getString("kind"));
+ final long vaultInCount = (long) today.get("vaultInCount");
+ final long depositCount = (long) today.get("depositCount");
+ final long withdrawalCount = (long) today.get("withdrawalCount");
+ final long settledOutCount = (long) today.get("settledOutCount");
+ final BigDecimal vaultInAmount = (BigDecimal) today.get("vaultInAmount");
+ final BigDecimal depositAmount = (BigDecimal) today.get("depositAmount");
+ final BigDecimal withdrawalAmount = (BigDecimal) today.get("withdrawalAmount");
+ final BigDecimal settledOutAmount = (BigDecimal) today.get("settledOutAmount");
+
+ final Map row = new LinkedHashMap<>();
+ row.put("denominationId", denomId);
+ row.put("denominationValue", today.get("denominationValue"));
+ row.put("kind", today.get("kind"));
+ row.put("openingCount", openingCount);
+ row.put("openingAmount", openingAmount);
row.put("vaultInCount", vaultInCount);
row.put("vaultInAmount", vaultInAmount);
row.put("depositCount", depositCount);
@@ -587,13 +553,18 @@ public class CashReportApiResource {
row.put("withdrawalAmount", withdrawalAmount);
row.put("settledOutCount", settledOutCount);
row.put("settledOutAmount", settledOutAmount);
- // What is left of this denomination: everything that came in, less everything that went
- // out. This is the figure a teller counts their notes against, per note.
- row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
- row.put("netAmount",
- vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
- return row;
- }, args.toArray());
+ // What is left of this denomination: whatever the drawer already held, plus what came in
+ // today, less what went out today. This is the figure a teller counts their notes
+ // against, per note.
+ row.put("netCount", openingCount + vaultInCount + depositCount - withdrawalCount - settledOutCount);
+ row.put("netAmount", openingAmount.add(vaultInAmount).add(depositAmount).subtract(withdrawalAmount)
+ .subtract(settledOutAmount));
+ rows.add(row);
+ }
+ rows.sort((a, b) -> {
+ final int byValue = ((Integer) b.get("denominationValue")).compareTo((Integer) a.get("denominationValue"));
+ return byValue != 0 ? byValue : String.valueOf(a.get("kind")).compareTo(String.valueOf(b.get("kind")));
+ });
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
@@ -762,6 +733,105 @@ public class CashReportApiResource {
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
* report and the position screen can never disagree about the same drawer.
*/
+ /**
+ * The four legs of {@link #closing} -- counter work, vault-in and settled-out -- summed per
+ * denomination, with {@code dateComparison} deciding which side of the target date each leg is
+ * read from ({@code "= CAST(? AS DATE)"} for the day itself, {@code "< CAST(? AS DATE)"} for
+ * everything before it). One query shape shared by both calls in {@link #closing}, rather than
+ * two copies that could drift apart.
+ */
+ private Map> fetchLegTotals(final String dateComparison, final String dateBound,
+ final Long scopedCashierId) {
+ final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ + " SELECT au.id FROM m_appuser au "
+ + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
+ final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
+ final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
+
+ final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ + "FROM ("
+ + " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ + " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ + " FROM m_savings_txn_denomination std "
+ + " WHERE std.txn_date " + dateComparison + " " + cashierFilter + COUNTER_NOT_REVERSED
+ + " UNION ALL "
+ + " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ + " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ + " WHERE ct.transfer_date " + dateComparison + " AND COALESCE(ct.is_reversed, false) = false "
+ + vaultCashierFilter
+ + " UNION ALL "
+ + " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ + " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ + " WHERE cs.settlement_date " + dateComparison + " AND COALESCE(cs.is_reversed, false) = false "
+ + settleCashierFilter
+ + ") legs "
+ + "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ + "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ + "ORDER BY legs.denomination_value DESC, dm.kind";
+
+ final List args = new ArrayList<>();
+ args.add(dateBound);
+ if (scopedCashierId != null) {
+ args.add(scopedCashierId);
+ }
+ args.add(dateBound);
+ if (scopedCashierId != null) {
+ args.add(scopedCashierId);
+ }
+ args.add(dateBound);
+ if (scopedCashierId != null) {
+ args.add(scopedCashierId);
+ }
+
+ final Map> byDenomination = new LinkedHashMap<>();
+ jdbcTemplate.query(sql, (rs, rowNum) -> {
+ final Map row = new LinkedHashMap<>();
+ final Long denomId = rs.getLong("denomination_id");
+ row.put("denominationId", denomId);
+ row.put("denominationValue", rs.getInt("denomination_value"));
+ row.put("kind", rs.getString("kind"));
+ row.put("vaultInCount", rs.getLong("vault_in_count"));
+ row.put("vaultInAmount", rs.getBigDecimal("vault_in_amount"));
+ row.put("depositCount", rs.getLong("deposit_count"));
+ row.put("depositAmount", rs.getBigDecimal("deposit_amount"));
+ row.put("withdrawalCount", rs.getLong("withdrawal_count"));
+ row.put("withdrawalAmount", rs.getBigDecimal("withdrawal_amount"));
+ row.put("settledOutCount", rs.getLong("settled_out_count"));
+ row.put("settledOutAmount", rs.getBigDecimal("settled_out_amount"));
+ byDenomination.put(denomId, row);
+ return row;
+ }, args.toArray());
+ return byDenomination;
+ }
+
+ /** A zero-activity "today" row shaped like {@link #fetchLegTotals}'s own rows, for a denomination
+ * that carried an opening balance into this date but saw no movement of its own on it -- without
+ * this, that denomination would vanish from the report instead of showing its carried-forward
+ * count untouched. */
+ private Map emptyLegRow(final Map template) {
+ final Map row = new LinkedHashMap<>();
+ row.put("denominationId", template.get("denominationId"));
+ row.put("denominationValue", template.get("denominationValue"));
+ row.put("kind", template.get("kind"));
+ row.put("vaultInCount", 0L);
+ row.put("vaultInAmount", BigDecimal.ZERO);
+ row.put("depositCount", 0L);
+ row.put("depositAmount", BigDecimal.ZERO);
+ row.put("withdrawalCount", 0L);
+ row.put("withdrawalAmount", BigDecimal.ZERO);
+ row.put("settledOutCount", 0L);
+ row.put("settledOutAmount", BigDecimal.ZERO);
+ return row;
+ }
+
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
final Map position = buildPositionForCashier(cashierId, asOfDate);
final Object total = position.get("totalAmount");
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
index b45a7fe52..23004022d 100644
--- 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
@@ -24,6 +24,8 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -150,7 +152,7 @@ public class ShareAdvanceCollectionApiResource {
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
- final String txnId = "SHADV-" + System.nanoTime();
+ final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE);
final String receiptNo = nextReceiptNo();
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
@@ -186,11 +188,25 @@ public class ShareAdvanceCollectionApiResource {
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
+ @Transactional
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
+ // A voucher explicitly tagged Share Collection at save time no longer waits for the counter
+ // to search it up by hand -- it's pulled onto this list itself, the first time anyone asks
+ // for it. Anything not tagged this way (an older row, or one deliberately left General) still
+ // needs the by-voucher search below, same as before.
+ autoAdoptShareCollectionTagged(clientId);
+
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 "
+ + " cl.display_name AS client_name, "
+ + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
+ // The GL account this specific receipt's money actually sits in -- its own posting's
+ // credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL
+ // Account picker above can be kept in sync with whichever receipt gets ticked.
+ + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ + " ORDER BY je.id LIMIT 1) AS gl_code "
+ " 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);
@@ -231,7 +247,8 @@ public class ShareAdvanceCollectionApiResource {
// 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 "
+ + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
+ + " 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);
@@ -271,19 +288,42 @@ public class ShareAdvanceCollectionApiResource {
// GET — GL accounts selectable for a by-voucher lookup
// ══════════════════════════════════════════════════════════════════════
/**
- * Every active, postable GL account, for the GL account picker on the Apply Collection screen.
+ * The GL account picker on the Apply Collection screen — narrowed to accounts that actually have
+ * an APPROVED, not-yet-consumed cashier deposit or clerk collection sitting in them right now,
+ * so the counter is choosing from real, live money rather than the entire chart of accounts
+ * (which is every postable GL account in the bank, share-related or not, well over a hundred of
+ * them, Cash at Vault included, with nothing to say which one has anything to find).
*
- * Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
- * point at today — that mapping is exactly what this lookup no longer trusts, since it can go
- * missing or wrong without anyone noticing until a voucher search fails at the counter. The person
- * applying a receipt picks the account the deposit actually went to, every time.
+ * Still never resolved from a share product's own Share Suspense mapping -- that mapping is
+ * exactly what this lookup no longer trusts, since it can go missing or wrong without anyone
+ * noticing until a voucher search fails at the counter. This filters by what genuinely sits in
+ * each account today, not by what any product's configuration claims should. Falls back to the
+ * unfiltered full list on the rare chance nothing currently qualifies, so the picker is never a
+ * dead end -- a voucher search should not become unreachable just because this narrowing has
+ * nothing to narrow to yet.
*/
@GET
@Path("/glaccounts")
public Response glAccounts() {
this.context.authenticatedUser();
- final List> rows = jdbcTemplate.queryForList(
- "SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
+ List> rows = jdbcTemplate.queryForList(
+ "SELECT DISTINCT ga.id, ga.gl_code, ga.name"
+ + " FROM acc_gl_account ga"
+ + " WHERE ga.disabled = false AND ga.account_usage = 1"
+ + " AND ("
+ + " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id"
+ + " AND d.status = 'APPROVED' AND d.share_account_id IS NULL"
+ + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"
+ + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id))"
+ + " OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request d WHERE d.gl_account_id = ga.id"
+ + " AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no))"
+ + " )"
+ + " ORDER BY ga.gl_code");
+ if (rows.isEmpty()) {
+ rows = jdbcTemplate.queryForList(
+ "SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
+ }
final Map out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
@@ -316,6 +356,47 @@ public class ShareAdvanceCollectionApiResource {
return Response.ok(gson.toJson(out)).build();
}
+ // ══════════════════════════════════════════════════════════════════════
+ // GET — The actual Dr/Cr GL legs a receipt posted, for the details popup
+ // ══════════════════════════════════════════════════════════════════════
+ /**
+ * What this receipt actually did to the ledger — the real Dr/Cr legs under its own
+ * {@code gl_transaction_id}, not the GL account currently picked in the by-voucher search box
+ * (a different, unrelated piece of screen state that a details popup must never be shown
+ * against, since it can name a completely different account than the one this receipt sits in).
+ */
+ @GET
+ @Path("/{collectionId}/gl-entries")
+ public Response glEntries(@PathParam("collectionId") final Long collectionId) {
+ this.context.authenticatedUser();
+ final List> collRows = jdbcTemplate
+ .queryForList("SELECT gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId);
+ if (collRows.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", "Receipt " + collectionId + " not found.");
+ }
+ final String glTransactionId = (String) collRows.get(0).get("gl_transaction_id");
+ final List> legs = glTransactionId == null ? List.of()
+ : jdbcTemplate.queryForList(
+ "SELECT ga.gl_code, ga.name AS account_name, je.type_enum, je.amount"
+ + " FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ + " WHERE je.transaction_id = ? AND je.reversed = false ORDER BY je.type_enum DESC, je.id",
+ glTransactionId);
+
+ final Map out = new LinkedHashMap<>();
+ out.put("transactionId", glTransactionId);
+ final List> entries = new java.util.ArrayList<>();
+ for (final Map leg : legs) {
+ final Map e = new LinkedHashMap<>();
+ e.put("glCode", leg.get("gl_code"));
+ e.put("accountName", leg.get("account_name"));
+ e.put("drCr", toInt(leg.get("type_enum")) == DEBIT ? "Dr" : "Cr");
+ e.put("amount", leg.get("amount"));
+ entries.add(e);
+ }
+ out.put("entries", entries);
+ return Response.ok(gson.toJson(out)).build();
+ }
+
// ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════
@@ -472,14 +553,26 @@ public class ShareAdvanceCollectionApiResource {
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 txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE_APPLIED);
final String accountNo = (String) acct.get("account_no");
- // Dr Sundry for the share-side receipts, including any over-payment on them — the liability
- // those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
- // it was never in Sundry, so nothing here should debit Sundry for it.
- insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
- "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
+ // Dr whichever account each receipt's money is actually sitting in, receipt by receipt,
+ // including any over-payment on them — the liability those receipts created is cleared in
+ // full. A verified fee receipt is deliberately excluded: its money was never held for the
+ // shares, so nothing here should move it.
+ //
+ // Read back from the collection's own posting rather than resolved from the product's Sundry
+ // mapping. The cashier does not always deposit into Sundry -- an entry fee goes straight to
+ // its income account, and instalments taken on different days can land in different accounts
+ // -- so debiting the mapped Sundry account for every receipt took money out of an account
+ // that had never received it, and left the account that had holding the balance for ever.
+ // Debiting each receipt's own account is also what the clerk sees: the GL picked against the
+ // voucher on the apply screen is the account being cleared here.
+ for (final Map row : collections) {
+ final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense);
+ insertEntry(officeId, receiptGl, DEBIT, (BigDecimal) row.get("amount"), txnId, entryDate, shareAccountId, userId, now,
+ currencyCode, "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo);
+ }
// Cr Share Capital for the share value.
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
@@ -595,6 +688,108 @@ public class ShareAdvanceCollectionApiResource {
* missing product mapping can never make a genuine voucher unreachable.
* @return the newly created collection row, or null when no approved deposit matches.
*/
+ /**
+ * Pulls every APPROVED, not-yet-consumed deposit or clerk collection explicitly tagged "Share
+ * Collection" into m_share_advance_collection, so it shows up on the Apply Share / Additional
+ * Share screen on its own -- the point of tagging a voucher this way at save time in the first
+ * place.
+ *
+ * A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own --
+ * buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of
+ * these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER
+ * auto-adopted, for any client, under any circumstances -- attributing it to "whichever member's
+ * screen happened to be open when this ran" is a coincidence of timing, not evidence of whose
+ * money it is, and would silently hand one member's cash to a completely unrelated member simply
+ * because their Apply Share screen loaded first. A clientless receipt can only ever be claimed
+ * through the manual by-voucher search, where a person at the counter is actively vouching for it
+ * against a member they can see, by quoting the specific voucher number the member is holding --
+ * a deliberate, checkable act, not an automatic guess. A row that DOES carry its own client_id
+ * (a Clerk Collection, whose client comes from the savings account it actually debited -- real
+ * evidence, not a guess) is still only adopted against a matching client_id, never a different
+ * member than the one asking.
+ */
+ private void autoAdoptShareCollectionTagged(final Long clientIdFilter) {
+ final List> deposits = jdbcTemplate.queryForList(
+ "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id,"
+ + " d.cashier_id, d.cashier_name, d.description"
+ + " FROM m_cashier_deposit_request d"
+ + " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ + " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.share_account_id IS NULL"
+ + " AND d.client_id IS NOT NULL"
+ + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
+ + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
+ for (final Map dep : deposits) {
+ final Long resolvedClientId = toLong(dep.get("client_id"));
+ // Guaranteed non-null by the WHERE clause above, but a genuine client_id must still match
+ // who is asking -- never adopted for a different member than the one whose screen it is.
+ if (clientIdFilter != null && !clientIdFilter.equals(resolvedClientId)) {
+ continue;
+ }
+ 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();
+ // What the cashier actually typed at save time, when they typed anything -- that is what
+ // the Apply Share screen's Description column exists to show. Falls back to a plain
+ // label only for a row nobody described, same as the manual by-voucher adoption below.
+ final String depDescription = (String) dep.get("description");
+ final String note = depDescription != null && !depDescription.isBlank() ? depDescription
+ : "Share collection - Voucher " + dep.get("voucher_no");
+ 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, dep.get("voucher_no"), resolvedClientId, toLong(dep.get("office_id")),
+ java.sql.Date.valueOf(collectionDate), dep.get("amount"), dep.get("currency_code"), dep.get("txn_id"),
+ toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
+ }
+
+ final List> collections = jdbcTemplate.queryForList(
+ "SELECT d.id, d.voucher_no, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
+ + " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id"
+ + " FROM m_clerk_disbursement_request d"
+ + " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ + " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
+ for (final Map req : collections) {
+ final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
+ if (savingsAccountId == null) {
+ continue;
+ }
+ final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
+ savingsAccountId);
+ if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) {
+ continue;
+ }
+ final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId);
+ final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
+ final Object actionedAt = req.get("actioned_at");
+ final Object submittedAt = req.get("submitted_at");
+ final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
+ ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
+ : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
+ final String voucherNo = (String) req.get("voucher_no");
+ final String receiptNo = nextReceiptNo();
+ final String reqDescription = (String) req.get("description");
+ final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
+ : "Share collection - Voucher " + voucherNo;
+ // The real acc_gl_journal_entry.transaction_id this approval posted (see
+ // 0566_add_gl_transaction_id_to_clerk_disbursement_request.xml) -- falls back to the
+ // voucher number only for a row approved before that column existed, since something has
+ // to go in a NOT NULL-shaped identity column and the voucher number at least resolves
+ // back to this same row.
+ final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
+ 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, memberClientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency,
+ glTxnId, toLong(req.get("clerk_id")), req.get("clerk_name"), note);
+ }
+ }
+
private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
@@ -604,8 +799,10 @@ public class ShareAdvanceCollectionApiResource {
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"
+ + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id,"
+ + " cv.code_value AS entry_type"
+ " FROM m_cashier_deposit_request d"
+ + " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, shareSundryAccountNo);
@@ -618,6 +815,23 @@ public class ShareAdvanceCollectionApiResource {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been applied to a share account.");
}
+ // The counter tags what a GL receipt was actually collected for at save time, from the
+ // GLEntryType code (Admin > System > Manage Codes), precisely so a manager here can never
+ // pick up money that was really meant for something else (a Demand Draft, say) just because
+ // it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows
+ // from before this tag existed) are still adoptable -- only an explicit, different purpose
+ // blocks it.
+ final String depositEntryType = (String) dep.get("entry_type");
+ if (depositEntryType != null && !"General".equals(depositEntryType) && !"Share Collection".equals(depositEntryType)) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
+ "Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. "
+ + "Choose a different voucher, or ask the cashier to correct the entry type.");
+ }
+ if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?",
+ toLong(dep.get("id"))).isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
+ "Voucher " + voucherNo + " has already been used to issue a Demand Draft.");
+ }
// 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;
@@ -643,7 +857,7 @@ public class ShareAdvanceCollectionApiResource {
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"
+ + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, 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);
}
@@ -665,16 +879,27 @@ public class ShareAdvanceCollectionApiResource {
private Map adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
final String glAccountNo = glAccountNoOverride.trim();
final List> requests = jdbcTemplate.queryForList(
- "SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
- + " FROM m_clerk_disbursement_request"
- + " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
- + " ORDER BY id DESC",
+ "SELECT d.id, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
+ + " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id, cv.code_value AS entry_type"
+ + " FROM m_clerk_disbursement_request d"
+ + " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ + " WHERE d.voucher_no = ? AND TRIM(d.gl_account_no) = ? AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ + " ORDER BY d.id DESC",
voucherNo, glAccountNo);
if (requests.isEmpty()) {
return null;
}
final Map req = requests.get(0);
+ // Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL
+ // account the counter picked by hand, so it carries the identical ambiguity.
+ final String reqEntryType = (String) req.get("entry_type");
+ if (reqEntryType != null && !"General".equals(reqEntryType) && !"Share Collection".equals(reqEntryType)) {
+ throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
+ "Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. "
+ + "Choose a different voucher, or ask the clerk to correct the entry type.");
+ }
+
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
@@ -692,16 +917,20 @@ public class ShareAdvanceCollectionApiResource {
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
+ final String reqDescription = (String) req.get("description");
+ final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
+ : "Adopted from clerk disbursement voucher " + voucherNo;
+ final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
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), req.get("dr_amount"), currency, voucherNo,
- toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo);
+ receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, glTxnId,
+ toLong(req.get("clerk_id")), req.get("clerk_name"), note);
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"
+ + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, 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);
}
@@ -809,6 +1038,37 @@ public class ShareAdvanceCollectionApiResource {
}
}
+ /**
+ * The account a receipt's money is actually sitting in, read back from the posting that put it
+ * there — the credit leg of the collection's own journal transaction.
+ *
+ *
+ * Whoever took the money decided where it went: this register's own collection credits Sundry, a
+ * cashier deposit credits whichever account the cashier posted against, and an entry fee usually
+ * goes straight to fee income. Reading the account back is therefore the only way to know it, and
+ * it matches what the clerk picks against the voucher on the apply screen.
+ *
+ *
+ * Falls back to the product's mapped Sundry account for a collection with no posting recorded
+ * against it, which is where anything taken before this register started stamping its transaction
+ * id was held.
+ *
+ *
+ * Scoped to the live credit leg, newest first: a transaction id is only as unique as whatever
+ * minted it, and picking the most recent match keeps this correct even if one is ever reused.
+ */
+ private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) {
+ if (glTransactionId == null || glTransactionId.isBlank()) {
+ return fallbackGlId;
+ }
+ final List rows = jdbcTemplate.queryForList("""
+ SELECT account_id FROM acc_gl_journal_entry
+ WHERE transaction_id = ? AND type_enum = ? AND reversed = false
+ ORDER BY id DESC LIMIT 1
+ """, Long.class, glTransactionId, CREDIT);
+ return rows.isEmpty() ? fallbackGlId : rows.get(0);
+ }
+
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
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
deleted file mode 100644
index 1ee09f3a3..000000000
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java
+++ /dev/null
@@ -1,439 +0,0 @@
-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.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.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import lombok.RequiredArgsConstructor;
-import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
-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.dao.EmptyResultDataAccessException;
-import org.springframework.dao.IncorrectResultSizeDataAccessException;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-import org.apache.fineract.infrastructure.core.service.DateUtils;
-
-/**
- * Cashier Share Collection API — POST /v1/cashier/sharecollection
- *
- * Records cash received from a member for share purchase and posts GL entries:
- * Dr SHARES_REFERENCE (Cash at Vault) = shareAmount + chargeAmount
- * Cr SHARES_SUSPENSE (Share Sundry) = shareAmount
- * Cr INCOME_FROM_FEES = chargeAmount (if > 0)
- *
- * On account activation Fineract posts:
- * Dr SHARES_SUSPENSE → Cr SHARES_EQUITY
- * Activation skips charge GL if is_paid_derived = true (set here).
- */
-@Path("/v1/cashier/sharecollection")
-@Component
-@RequiredArgsConstructor
-@Consumes({ MediaType.APPLICATION_JSON })
-@Produces({ MediaType.APPLICATION_JSON })
-public class ShareCollectionApiResource {
-
- // ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
- // Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr)
- // Opposite of intuition — verified from Fineract AccountingProcessorHelper.
- 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;
-
- // ── acc_gl_journal_entry.entity_type_enum ─────────────────────────────
- // Manual cashier entries use NULL — avoids NPE in PortfolioProductType.fromInt()
- // which only handles values 1-5 and returns null for unknown values.
-
- // ── 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_SUBMITTED_PENDING = 100;
-
- // ── Maximum days a collection record is considered valid ──────────────
- private static final int MAX_COLLECTION_AGE_DAYS = 30;
-
- private final PlatformSecurityContext context;
- private final JdbcTemplate jdbcTemplate;
- private final Gson gson = new Gson();
-
- // ══════════════════════════════════════════════════════════════════════
- // POST — Collect cash for a share account
- // ══════════════════════════════════════════════════════════════════════
- @POST
- @Transactional
- public Response collect(final String body) {
-
- final AppUser currentUser = this.context.authenticatedUser();
- currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
-
- // ── Parse request ──────────────────────────────────────────────────
- if (body == null || body.isBlank()) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.request.empty",
- "Request body is required.");
- }
- final JsonObject json;
- try {
- json = JsonParser.parseString(body).getAsJsonObject();
- } catch (Exception e) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.request.invalid",
- "Request body must be valid JSON.");
- }
-
- final Long shareAccountId = getLong(json, "shareAccountId");
- final String transactionDateStr = getString(json, "transactionDate");
- final BigDecimal shareAmount = getBigDecimal(json, "shareAmount", "shareAmount");
- final BigDecimal chargeAmountRaw = getBigDecimal(json, "chargeAmount", "chargeAmount");
- final String note = sanitiseNote(getString(json, "note"));
-
- // ── Validate inputs ────────────────────────────────────────────────
- if (shareAccountId == null) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.accountid.required",
- "shareAccountId is required.");
- }
- if (shareAmount.compareTo(BigDecimal.ZERO) < 0) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.shareamount.negative",
- "shareAmount cannot be negative.");
- }
- final BigDecimal safeCharge = chargeAmountRaw.compareTo(BigDecimal.ZERO) > 0
- ? chargeAmountRaw : BigDecimal.ZERO;
- final BigDecimal totalAmount = shareAmount.add(safeCharge);
- if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.total.zero",
- "Total collection amount must be greater than zero.");
- }
-
- final LocalDate entryDate;
- try {
- entryDate = transactionDateStr != null
- ? LocalDate.parse(transactionDateStr) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
- } catch (DateTimeParseException e) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.date.invalid",
- "transactionDate must be in YYYY-MM-DD format. Received: " + transactionDateStr);
- }
-
- // ── Load account + client details ──────────────────────────────────
- final Map acct = loadAccount(shareAccountId);
-
- // ── Account must be in 'Submitted and pending approval' ───────────
- final int statusEnum = toInt(acct.get("status_enum"));
- if (statusEnum != STATUS_SUBMITTED_PENDING) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.account.invalid.status",
- "Cash collection is only allowed for accounts in 'Submitted and pending approval' "
- + "status. Account " + acct.get("account_no") + " has status code " + statusEnum + ".");
- }
-
- // ── Prevent double collection ──────────────────────────────────────
- if (Boolean.TRUE.equals(acct.get("cashier_collected"))) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.already.collected",
- "Cash has already been collected for share account "
- + acct.get("account_no") + ". Re-collection is not allowed.");
- }
-
- 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 String clientName = (String) acct.get("client_name");
- final String accountNo = (String) acct.get("account_no");
-
- // ── Resolve GL accounts from product mapping ───────────────────────
- final Long glReference = resolveGl(productId, FA_SHARES_REFERENCE);
- final Long glSuspense = resolveGl(productId, FA_SHARES_SUSPENSE);
- final Long glIncome = resolveGl(productId, FA_INCOME_FROM_FEES);
-
- if (glReference == null) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.gl.reference.notmapped",
- "Share Reference (Cash at Vault) GL is not mapped for product " + productId
- + ". Configure Share Product accounting mappings first.");
- }
- if (glSuspense == null) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.gl.suspense.notmapped",
- "Share Suspense GL is not mapped for product " + productId
- + ". Configure Share Product accounting mappings first.");
- }
- if (safeCharge.compareTo(BigDecimal.ZERO) > 0 && glIncome == null) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.gl.income.notmapped",
- "Income from Fees GL is not mapped for product " + productId
- + " but charge amount " + safeCharge + " is present. "
- + "Configure Share Product accounting mappings first.");
- }
-
- // ── Generate unique transaction ID ─────────────────────────────────
- final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis();
- final long userId = currentUser.getId();
- final String collectorName = currentUser.getUsername();
- final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
-
- // ── Post GL journal entries ────────────────────────────────────────
- // Dr Cash at Vault (SHARES_REFERENCE) = totalAmount
- insertEntry(officeId, glReference, DEBIT, totalAmount, txnId,
- entryDate, shareAccountId, userId, now, currencyCode,
- "Share collection received for " + accountNo
- + (note != null ? " | " + note : ""));
-
- // Cr Share Sundry (SHARES_SUSPENSE) = shareAmount
- insertEntry(officeId, glSuspense, CREDIT, shareAmount, txnId,
- entryDate, shareAccountId, userId, now, currencyCode,
- "Share amount held in suspense - " + accountNo);
-
- // Cr Income from Fees = chargeAmount (only if > 0)
- if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
- insertEntry(officeId, glIncome, CREDIT, safeCharge, txnId,
- entryDate, shareAccountId, userId, now, currencyCode,
- "Charge collected upfront for " + accountNo);
-
- // Mark charges paid → activation will skip duplicate charge GL entry
- 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 AND is_paid_derived = false",
- shareAccountId);
- }
-
- // ── Flip cashier_collected flag ────────────────────────────────────
- jdbcTemplate.update(
- "UPDATE m_share_account SET cashier_collected = true WHERE id = ?",
- shareAccountId);
-
- // ── Insert audit / validation record ──────────────────────────────
- jdbcTemplate.update(
- "INSERT INTO m_share_cashier_collection "
- + "(share_account_id, collected_by_user, collected_by_name, collection_date, "
- + " share_amount, charge_amount, total_amount, gl_transaction_id, "
- + " office_id, currency_code, note) "
- + "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
- shareAccountId, userId, collectorName,
- java.sql.Date.valueOf(entryDate),
- shareAmount, safeCharge, totalAmount,
- txnId, officeId, currencyCode, note);
-
- // ── Build GL entries summary for response ──────────────────────────
- final List> glEntries = new ArrayList<>();
- glEntries.add(glEntryMap(resolveGlName(glReference), "Dr", totalAmount, currencyCode));
- glEntries.add(glEntryMap(resolveGlName(glSuspense), "Cr", shareAmount, currencyCode));
- if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
- glEntries.add(glEntryMap(resolveGlName(glIncome), "Cr", safeCharge, currencyCode));
- }
-
- // ── Response ───────────────────────────────────────────────────────
- final Map resp = new LinkedHashMap<>();
- resp.put("transactionId", txnId);
- resp.put("shareAccountId", shareAccountId);
- resp.put("accountNo", accountNo);
- resp.put("clientName", clientName);
- resp.put("collectionDate", entryDate.toString());
- resp.put("collectedBy", collectorName);
- resp.put("shareAmount", shareAmount);
- resp.put("chargeAmount", safeCharge);
- resp.put("totalAmount", totalAmount);
- resp.put("currencyCode", currencyCode);
- resp.put("glEntries", glEntries);
-
- return Response.ok(gson.toJson(resp)).build();
- }
-
- // ══════════════════════════════════════════════════════════════════════
- // GET — Check collection status for a share account
- // ══════════════════════════════════════════════════════════════════════
- @GET
- @Path("/{shareAccountId}")
- public Response getStatus(@PathParam("shareAccountId") final Long shareAccountId) {
- this.context.authenticatedUser();
-
- try {
- final Map rec = jdbcTemplate.queryForMap(
- "SELECT scc.share_account_id, sa.account_no, "
- + " mc.display_name AS client_name, "
- + " scc.collected_by_name, scc.collection_date, "
- + " scc.share_amount, scc.charge_amount, scc.total_amount, "
- + " scc.gl_transaction_id, scc.currency_code, scc.note "
- + "FROM m_share_cashier_collection scc "
- + "JOIN m_share_account sa ON sa.id = scc.share_account_id "
- + "JOIN m_client mc ON mc.id = sa.client_id "
- + "WHERE scc.share_account_id = ?",
- shareAccountId);
-
- final Map resp = new LinkedHashMap<>();
- resp.put("collected", true);
- resp.put("shareAccountId", shareAccountId);
- resp.put("accountNo", rec.get("account_no"));
- resp.put("clientName", rec.get("client_name"));
- resp.put("collectedBy", rec.get("collected_by_name"));
- resp.put("collectionDate", rec.get("collection_date") != null
- ? rec.get("collection_date").toString() : null);
- resp.put("shareAmount", rec.get("share_amount"));
- resp.put("chargeAmount", rec.get("charge_amount"));
- resp.put("totalAmount", rec.get("total_amount"));
- resp.put("transactionId", rec.get("gl_transaction_id"));
- resp.put("currencyCode", rec.get("currency_code"));
- resp.put("note", rec.get("note"));
- return Response.ok(gson.toJson(resp)).build();
-
- } catch (EmptyResultDataAccessException e) {
- final Map resp = new LinkedHashMap<>();
- resp.put("collected", false);
- resp.put("shareAccountId", shareAccountId);
- return Response.ok(gson.toJson(resp)).build();
- }
- }
-
- // ══════════════════════════════════════════════════════════════════════
- // Private helpers
- // ══════════════════════════════════════════════════════════════════════
-
- private Map loadAccount(final Long shareAccountId) {
- try {
- return jdbcTemplate.queryForMap(
- "SELECT sa.id, sa.account_no, sa.status_enum, sa.product_id, "
- + " sa.cashier_collected, sa.currency_code, "
- + " mc.office_id, mc.display_name AS client_name "
- + "FROM m_share_account sa "
- + "JOIN m_client mc ON mc.id = sa.client_id "
- + "WHERE sa.id = ?",
- shareAccountId);
- } catch (EmptyResultDataAccessException e) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.account.notfound",
- "Share account with id " + shareAccountId + " does not exist.");
- }
- }
-
- 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 = ?",
- Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
- } catch (IncorrectResultSizeDataAccessException e) {
- // covers both EmptyResultDataAccessException (0 rows) and multiple rows
- return null;
- }
- }
-
- private String resolveGlName(final Long glAccountId) {
- if (glAccountId == null) return "Unknown GL";
- try {
- return jdbcTemplate.queryForObject(
- "SELECT name FROM acc_gl_account WHERE id = ?",
- String.class, glAccountId);
- } catch (EmptyResultDataAccessException e) {
- return "GL-" + glAccountId;
- }
- }
-
- 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) {
- GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
- 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, // entity_id — links entry to the share account
- 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 Map glEntryMap(final String account, final String type,
- final BigDecimal amount, final String currency) {
- final Map m = new LinkedHashMap<>();
- m.put("account", account);
- m.put("type", type);
- m.put("amount", amount);
- m.put("currency", currency);
- return m;
- }
-
- 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 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;
- }
-
- private Long getLong(final JsonObject json, final String key) {
- return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
- }
-
- private BigDecimal getBigDecimal(final JsonObject json, final String key,
- final String fieldLabel) {
- if (!json.has(key) || json.get(key).isJsonNull()) return BigDecimal.ZERO;
- try {
- return json.get(key).getAsBigDecimal();
- } catch (NumberFormatException e) {
- throw new PlatformDataIntegrityException(
- "error.msg.sharecollection.field.invalid.number",
- "Field '" + fieldLabel + "' must be a valid number.");
- }
- }
-
- private String getString(final JsonObject json, final String key) {
- return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
- }
-}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java
index e7e9ddcff..5c128b357 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java
@@ -389,8 +389,12 @@ public class ChequeBookIssuanceChargeService {
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
+ // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
+ // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
+ // stale, unrelated journal entry.
final List rowIds = this.jdbcTemplate.queryForList(
- "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ + " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java
index 852f9124e..9180b5bf6 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java
@@ -192,8 +192,12 @@ public class ClearingJournalService {
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
+ // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
+ // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
+ // stale, unrelated journal entry.
final List rowIds = this.jdbcTemplate.queryForList(
- "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ + " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
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 c9cce3fc3..a250b0e45 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
@@ -116,6 +116,12 @@ public class ClerkDisbursementRequestApiResource {
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
requestType = "DISBURSEMENT";
}
+ // What this GL leg is actually for — a code value under "GLEntryType" (Admin > System >
+ // Manage Codes). A COLLECTION credits a GL account the counter picks by hand, the same
+ // ambiguity a cashier Receipt has (see CashierDepositRequestApiResource and
+ // 0565_convert_entry_type_to_code_value.xml). ShareAdvanceCollectionApiResource checks this
+ // before adopting one of these rows as a share collection.
+ final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
throw apiError(400, "At least one entry is required");
@@ -145,6 +151,24 @@ public class ClerkDisbursementRequestApiResource {
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
throw apiError(400, "Each entry must have an account number");
}
+ // Whatever the counter searched with to find this account (often a short suffix, not the
+ // full number) is not what belongs on this record permanently — replace it here, once, at
+ // submit time, with the real number looked up from the id the search actually resolved.
+ // Leaves the entry's own accountNo untouched only when it carries no id to look up from
+ // (a GL leg's account/glAccountId is set, so this only bites when the search itself never
+ // matched anything, which submit-time validation elsewhere is already responsible for).
+ final String entryAccountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
+ final boolean entryIsGl = "GL".equals(entryAccountType);
+ final String realAccountNo = entryIsGl
+ ? (entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
+ ? glCodeOf(entry.get("glAccountId").getAsLong())
+ : null)
+ : (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
+ ? nonGlAccountNoOf(entryAccountType, entry.get("savingsAccountId").getAsLong())
+ : null);
+ if (realAccountNo != null) {
+ entry.addProperty("accountNo", realAccountNo);
+ }
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
// working, since nothing about a restricted customer should prevent them being paid.
@@ -180,21 +204,27 @@ public class ClerkDisbursementRequestApiResource {
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
+ // Same correction as each entry above, for the request's own top-level GL — and the entries
+ // are stored from the mutated array (each accountNo now the real one), not the original
+ // as-submitted JSON string.
+ final String realGlAccountNo = Optional.ofNullable(glCodeOf(glAccountId)).orElse(glAccountNo);
+ final String correctedCreditEntriesJson = gson.toJson(creditEntries);
+
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_disbursement_request
(gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code,
credit_entries, payment_type_id, payment_type_name, description,
- status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type, entry_type_cv_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
- glAccountId, glAccountNo, glAccountName, txnAmount,
+ glAccountId, realGlAccountNo, glAccountName, txnAmount,
officeId != null ? officeId : currentUser.getOffice().getId(), currencyCode,
- creditEntriesJson, paymentTypeId, paymentTypeName, description,
+ correctedCreditEntriesJson, paymentTypeId, paymentTypeName, description,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
- activityLog, Timestamp.from(Instant.now()), requestType
+ activityLog, Timestamp.from(Instant.now()), requestType, entryTypeCvId
);
// A collection pulls money into the GL, so it reads as a credit; a disbursement moves
@@ -282,7 +312,7 @@ public class ClerkDisbursementRequestApiResource {
+ " credit_entries, payment_type_name, description,"
+ " clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username,"
- + " rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
+ + " rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id"
+ " FROM m_clerk_disbursement_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -349,7 +379,7 @@ public class ClerkDisbursementRequestApiResource {
final List> rows = jdbcTemplate.queryForList(
"SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount,"
+ " credit_entries, payment_type_name, description, clerk_name,"
- + " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
+ + " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id"
+ " FROM m_clerk_disbursement_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -473,6 +503,11 @@ public class ClerkDisbursementRequestApiResource {
final List withdrawalSavingsTxnRefs = new ArrayList<>();
BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO;
Long collectionTargetGlId = null;
+ // The real acc_gl_journal_entry.transaction_id this approval posts -- captured here because
+ // nothing else on the row remembers it otherwise (txn_refs only holds the account-side
+ // transaction ids). Whoever needs the actual GL posting afterwards (Share Advance Collection
+ // adopting a tagged voucher, a GL detail view) reads it back from gl_transaction_id.
+ String approvedGlTxnId = null;
// Savings-control GL data per account for the direct combined journal entry.
// collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode).
@@ -538,6 +573,7 @@ public class ClerkDisbursementRequestApiResource {
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
}
+ approvedGlTxnId = btGlTxnId;
// Audit log in m_bank_transfer_log
final String btTxnRefsStr = String.join(",", txnRefs);
@@ -683,6 +719,7 @@ public class ClerkDisbursementRequestApiResource {
if (disbGlTxnId != null) {
linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1);
}
+ approvedGlTxnId = disbGlTxnId;
}
// ── Collection GL entry ───────────────────────────────────────────────
@@ -705,6 +742,7 @@ public class ClerkDisbursementRequestApiResource {
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
+ approvedGlTxnId = collGlTxnId;
} else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) {
@@ -756,11 +794,11 @@ public class ClerkDisbursementRequestApiResource {
jdbcTemplate.update("""
UPDATE m_clerk_disbursement_request SET
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
- txn_refs = ?, activity_log = ?, actioned_at = ?
+ txn_refs = ?, gl_transaction_id = ?, activity_log = ?, actioned_at = ?
WHERE id = ?
""",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
- txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id);
+ txnRefsStr, approvedGlTxnId, updatedLog, Timestamp.from(Instant.now()), id);
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
@@ -1107,6 +1145,34 @@ public class ClerkDisbursementRequestApiResource {
* A failure here does not fail the approval: every transaction is posted by this point, and the
* money has moved. The problem is logged, and the voucher keeps its number without sides.
*/
+ /** The GL account's own code, looked up fresh from its id -- never trust a caller-supplied string
+ * for what belongs on a voucher's GL-side line. Null-safe so a possibly-missing id passes through. */
+ private String glCodeOf(final Long glAccountId) {
+ if (glAccountId == null) {
+ return null;
+ }
+ return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
+ .findFirst().orElse(null);
+ }
+
+ /** The account's own account number, looked up fresh from its id -- same reasoning as
+ * {@link #glCodeOf}, for the non-GL side of a voucher line. The id travels in this entry's
+ * {@code savingsAccountId} field regardless of what kind of account it actually names (this
+ * request's JSON has no separate field per account type), but "CURRENT" accounts live in their
+ * own {@code m_current_account} table with their own id space -- looking every non-GL id up
+ * against {@code m_savings_account} would silently return an unrelated savings account's number
+ * whenever the two id spaces happened to collide, which is worse than the truncated-search-string
+ * bug this method exists to fix. Every other non-GL type (savings, fixed deposit, recurring
+ * deposit) is a {@code m_savings_account} row distinguished only by {@code deposit_type_enum}. */
+ private String nonGlAccountNoOf(final String accountType, final Long accountId) {
+ if (accountId == null) {
+ return null;
+ }
+ final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
+ return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
+ .findFirst().orElse(null);
+ }
+
private void addDisbursementVoucherLines(final Map, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) {
@@ -1124,12 +1190,28 @@ public class ClerkDisbursementRequestApiResource {
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
+ final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
+ final boolean isGlEntry = "GL".equals(accountType);
+
+ // For a Disbursement/Collection, the society's own GL side is added once as a single
+ // total further down (from sourceGlId, the actually-resolved id) -- not read per entry
+ // here. A Collection's payload names its GL leg inside creditEntries too (so the
+ // amounts balance for the request's own DR/CR validation at submit time), but if this
+ // loop also turned that same entry into a line, the GL account would be posted twice on
+ // one voucher: once here (wrongly signed, since entryIsDebit below ignores this entry's
+ // own dbCr and applies isCollection to every entry in the array) and again as the
+ // society's side, with counterpartyTotal inflated by the GL entry's own amount on top of
+ // it. A bank transfer has no separate society side and every entry, GL or not, is a real
+ // leg of the transfer, so nothing is skipped there.
+ if (!isBankTransfer && isGlEntry) {
+ continue;
+ }
+
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
- final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
+ final String enteredAccountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
? entry.get("accountName").getAsString()
- : accountNo;
- final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
+ : enteredAccountNo;
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
? entry.get("glAccountId").getAsLong()
@@ -1138,6 +1220,14 @@ public class ClerkDisbursementRequestApiResource {
? entry.get("savingsAccountId").getAsLong()
: null;
+ // Whatever the counter searched with to find this account (often a short suffix, not
+ // the full number) is never what belongs on the slip. The id is what was actually
+ // resolved and posted against, so look its real number up from the database rather
+ // than trust the string the client happened to submit alongside it -- the id is the
+ // only part of this entry that can't be typo'd or truncated.
+ final String accountNo = isGlEntry ? Optional.ofNullable(glCodeOf(glId)).orElse(enteredAccountNo)
+ : Optional.ofNullable(nonGlAccountNoOf(accountType, savingsId)).orElse(enteredAccountNo);
+
// A bank transfer states each side on the entry itself; the other two directions
// put every counterparty on the side opposite the society's GL.
final boolean entryIsDebit = isBankTransfer
@@ -1163,7 +1253,10 @@ public class ClerkDisbursementRequestApiResource {
// between them — so its entries already balance on their own.
if (!isBankTransfer) {
final String glName = (String) record.get("gl_account_name");
- final String glNo = (String) record.get("gl_account_no");
+ // Same reasoning as above: gl_account_no is whatever string was stored on this request
+ // at submit time, not necessarily this GL account's real code. sourceGlId is what was
+ // actually resolved and posted against.
+ final String glNo = Optional.ofNullable(glCodeOf(sourceGlId)).orElse((String) record.get("gl_account_no"));
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
}
@@ -1183,7 +1276,7 @@ public class ClerkDisbursementRequestApiResource {
office_id, currency_code, credit_entries, payment_type_id, payment_type_name,
description, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason,
- txn_refs, error_message, activity_log, submitted_at, actioned_at
+ txn_refs, error_message, activity_log, submitted_at, actioned_at, entry_type_cv_id
FROM m_clerk_disbursement_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -1242,6 +1335,18 @@ public class ClerkDisbursementRequestApiResource {
try { return new BigDecimal(raw); } catch (NumberFormatException e) { return null; }
}
+ /** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
+ * resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
+ private Long resolveEntryTypeCvId(final Long provided) {
+ if (provided != null) {
+ return provided;
+ }
+ return jdbcTemplate.queryForObject(
+ "SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
+ + " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
+ Long.class);
+ }
+
private String buildActivityLog(final String existingJson, final String action,
final String username, final String note) {
JsonArray log = new JsonArray();
@@ -1321,9 +1426,12 @@ public class ClerkDisbursementRequestApiResource {
private Long findCashVaultGlId(final List txnRefs, final int typeEnum) {
for (final String txnRef : txnRefs) {
+ // ORDER BY id DESC: if transaction_id text is ever reused (e.g. after a sequence
+ // reset), always prefer the most recently posted matching row over a stale one.
final List> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
- + " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
+ + " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ + " ORDER BY id DESC LIMIT 1",
"S" + txnRef, typeEnum);
if (!rows.isEmpty()) {
return ((Number) rows.get(0).get("account_id")).longValue();
@@ -1402,9 +1510,12 @@ public class ClerkDisbursementRequestApiResource {
* typeEnum=2 (DEBIT) → SavingsControl for withdrawals (DR SavingsControl / CR CashVault).
*/
private Long findAccountGlFromTxn(final String fullTxnId, final int typeEnum) {
+ // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text,
+ // in case the id is ever reused (e.g. after a sequence reset).
final List> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
- + " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
+ + " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ + " ORDER BY id DESC LIMIT 1",
fullTxnId, typeEnum);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
}
@@ -1417,8 +1528,11 @@ public class ClerkDisbursementRequestApiResource {
private void markTxnGlEntriesReversed(final List entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
+ // Guarded by reversed = false: if transaction_id text is ever reused, this can
+ // only flip rows that are currently live, never re-touch an already-reversed
+ // unrelated transaction's rows.
jdbcTemplate.update(
- "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?",
+ "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
e.txnId);
}
}
@@ -1429,7 +1543,7 @@ public class ClerkDisbursementRequestApiResource {
for (final GlEntry e : entries) {
if (e.txnId != null) {
jdbcTemplate.update(
- "UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?",
+ "UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true",
e.txnId);
}
}
@@ -1442,9 +1556,15 @@ public class ClerkDisbursementRequestApiResource {
*/
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List entries, final int typeEnum) {
+ // Take only the most recently posted `entries.size()` rows for this transaction_id text,
+ // then restore ascending id order. If the id text is ever reused (e.g. after a sequence
+ // reset), this keeps the positional zip below scoped to the rows this call itself just
+ // posted, instead of silently pulling in an older, unrelated combined entry's rows.
final List rowIds = jdbcTemplate.queryForList(
- "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? ORDER BY id ASC",
- Long.class, glTxnId, typeEnum);
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ + " ORDER BY id DESC LIMIT ?",
+ Long.class, glTxnId, typeEnum, entries.size());
+ java.util.Collections.reverse(rowIds);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
final Long savingsAccId = entries.get(i).savingsAccountId;
if (savingsAccId != null) {
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java
index a1f2a0cfe..5495eee7b 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java
@@ -50,6 +50,8 @@ import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -797,20 +799,31 @@ public class ClerkClosureRequestApiResource {
}
try {
final Map control = voucherService.savingsControlAccount(savingsAccountId);
- final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final String memberSideName = record.get("client_name") != null ? String.valueOf(record.get("client_name"))
: (String) control.get("name");
final String accountNo = (String) record.get("account_no");
final List lines = new ArrayList<>();
- lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount));
+ // This is the member's own savings account leg, not a GL posting -- debitGl(controlGlId,
+ // ...) named the shared Savings Control GL account here, mismatched against the member's
+ // own account number on the same line (the same bug class as the share-withdrawal voucher).
+ lines.add(VoucherLine.debitSavings(savingsAccountId, memberSideName, accountNo, closingAmount));
final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO);
if (roundingDirection > 0) {
- lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
+ final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
+ lines.add(VoucherLine.debitGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
}
- lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount));
+ // The same Cash at Vault / Cash at Teller account postRoundingAdjustment() actually posts
+ // to (resolveCashGlAccount()) — named and coded from the ledger rather than the fixed
+ // "Cash in Hand" / no-code placeholder this used to show, which said nothing when a reader
+ // wanted to confirm this voucher against the real entry.
+ final Map cashGlForVoucher = resolveCashGlAccount();
+ final Long cashGlIdForVoucher = ((Number) cashGlForVoucher.get("id")).longValue();
+ lines.add(VoucherLine.creditGl(cashGlIdForVoucher, (String) cashGlForVoucher.get("name"), glCodeOf(cashGlIdForVoucher),
+ cashierDisbursedAmount));
if (roundingDirection < 0) {
- lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
+ final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
+ lines.add(VoucherLine.creditGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
}
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
@@ -845,7 +858,7 @@ public class ClerkClosureRequestApiResource {
// Same business date as the closure this is adjusting, so the two never land on
// different days in the day book.
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
- final String transactionId = "CLOSEROUND-" + officeId + "-" + entryDate.toString().replace("-", "") + "-" + System.nanoTime();
+ final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CLOSURE_ROUNDING);
final Long cashGlId = ((Number) cashGl.get("id")).longValue();
// type_enum: 2 = DEBIT, 1 = CREDIT.
@@ -893,6 +906,28 @@ public class ClerkClosureRequestApiResource {
* Falls back to that code where the product has no mapping yet — every product created before
* the row existed is in that position, and a closure must not fail over a few paise.
*/
+ /** The GL account's own code, for a voucher's GL-side line — never the account/party this entry
+ * happens to relate to. Null-safe so a possibly-missing id passes through. */
+ private String glCodeOf(final Long glAccountId) {
+ if (glAccountId == null) {
+ return null;
+ }
+ return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
+ .findFirst().orElse(null);
+ }
+
+ /** The GL account's own configured name, for the party label on a voucher's GL-side line — fixed
+ * labels like "Round Off Account" stood in for whatever the product/tenant's own chart of
+ * accounts actually maps the rounding/cash GL to, so a voucher read as posting against a party
+ * that doesn't exist in the ledger whenever the real mapped account was named differently. */
+ private String glNameOf(final Long glAccountId) {
+ if (glAccountId == null) {
+ return "GL Account";
+ }
+ return jdbcTemplate.queryForList("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
+ .findFirst().orElse("GL Account");
+ }
+
private Long resolveRoundOffGlAccount(final Long savingsAccountId) {
if (savingsAccountId != null) {
final List mapped = jdbcTemplate.queryForList("""
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java
index 7e2a365fe..dc9690edf 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java
@@ -38,18 +38,23 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
+import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
+import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.ShareAccountingSuppression;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -64,6 +69,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -72,9 +79,10 @@ import org.springframework.transaction.annotation.Transactional;
* from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the
* governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which
* server-side executes the real Fineract "redeemshares" command against the share account, or
- * rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same
- * maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share
- * redemption is a book-entry, not a cash handover).
+ * rejects it. Settlement is always a SAVINGS book-transfer to the member's linked savings account —
+ * approval alone is terminal, see {@link #creditLinkedSavingsAccount} — never a cash payout at the
+ * teller counter (no cash denomination/disbursement leg; a share redemption is a book-entry, not a
+ * cash handover).
*/
@Path("/v1/clerk/share-withdrawal-requests")
@Component
@@ -100,24 +108,28 @@ public class ClerkShareWithdrawalRequestApiResource {
"clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id",
"activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id",
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
- "settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
+ "settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot", "settlement_mode",
+ "settlement_gl_account_id" };
- // "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of
- // the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a
- // pre-settlement_payment_type_id-column legacy request still resolves to a real payment type
- // instead of silently posting its settlement with none at all.
- private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER";
-
- // ── Sundry pass-through for the redemption settlement ───────────────────
+ // ── GL posting for the redemption and its settlement ────────────────────
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int CREDIT = 1;
private static final int DEBIT = 2;
+ // m_savings_account.status_enum: 300 = active.
+ private static final int SAVINGS_STATUS_ACTIVE = 300;
+
// acc_product_mapping.financial_account_type for share products.
- 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;
private static final int PRODUCT_TYPE_SHARES = 4;
+ // What Fineract's own bridge prefixes a share transaction's journal entries with
+ // (AccountingProcessorHelper.SHARE_TRANSACTION_IDENTIFIER). Reused so an entry this class posts
+ // in the platform's place groups under the id everything else already looks for.
+ private static final String SHARE_TXN_ID_PREFIX = "SH";
+
// 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;
@@ -142,6 +154,16 @@ public class ClerkShareWithdrawalRequestApiResource {
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final String description = fromJsonHelper.extractStringNamed("description", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
+ // 'SAVINGS' (default, and what every request submitted before this field existed carries)
+ // settles by book-transfer to the linked savings account; 'GL' settles by book-transfer to
+ // whichever GL account the clerk picked on the CR row instead (a settlement that never
+ // touches a member's own account) — see the settlement branch in approve(). 'NONE' is not
+ // accepted from the client at all; it is what 'GL' downgrades to below when the clerk's own
+ // chosen GL account turns out to be the redemption's own Sundry account.
+ final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
+ String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
+ : settlementModeRaw.trim().toUpperCase();
+ Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
// Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no.,
// amount, cheque no./date, token no., description, bank name, routing code, payment type) —
// stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads
@@ -169,8 +191,31 @@ public class ClerkShareWithdrawalRequestApiResource {
if (meetingDate == null || meetingDate.isBlank()) {
throw apiError(400, "Date of Meeting is required for a share withdrawal");
}
+ if (!"SAVINGS".equals(settlementMode) && !"GL".equals(settlementMode)) {
+ throw apiError(400, "Settlement Mode must be 'SAVINGS' or 'GL'");
+ }
+ if ("GL".equals(settlementMode)) {
+ if (settlementGlAccountId == null) {
+ throw apiError(400, "Settlement GL Account is required for a GL settlement");
+ }
+ GLAccountTransactionGuard.assertPostable(jdbcTemplate, settlementGlAccountId);
+ // A redemption that settles nowhere further credits the product's own Sundry GL --
+ // picking that same account as the settlement target here would name Sundry as both the
+ // source and the destination of the one entry, a self-cancelling posting with no real
+ // movement that no real CBS ledger allows. Rather than reject this
+ // outright, treat it as what the clerk actually means: "stop here, the value stays in
+ // Sundry" -- downgrade to 'NONE' so approve() posts nothing further and raises a voucher
+ // for exactly leg 1 (Dr Share / Cr Sundry) on its own, standalone.
+ final Long productIdForSundryCheck = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?",
+ Long.class, shareAccountId);
+ final Long glSundryForCheck = resolveGl(productIdForSundryCheck, FA_SHARES_SUSPENSE);
+ if (glSundryForCheck != null && glSundryForCheck.equals(settlementGlAccountId)) {
+ settlementMode = "NONE";
+ settlementGlAccountId = null;
+ }
+ }
if (settlementPaymentTypeId == null) {
- throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
+ throw apiError(400, "Settlement Payment Type is required — pick how the settlement should be credited");
}
validateSettlementPaymentType(settlementPaymentTypeId);
@@ -180,21 +225,23 @@ public class ClerkShareWithdrawalRequestApiResource {
INSERT INTO m_clerk_share_withdrawal_request
(share_account_id, account_no, client_id, client_name, requested_shares, requested_date,
resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username,
- activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot,
+ settlement_mode, settlement_gl_account_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate),
resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(),
- currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot);
+ currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot,
+ settlementMode, settlementGlAccountId);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
/**
- * Settlement is always a book-transfer from Share Sundry to the linked savings account — never a
- * cash payout at the teller counter — so cash-flagged payment types are rejected here regardless
- * of what the clerk picked in the UI.
+ * A SAVINGS or GL settlement is always a book-transfer — never a cash payout at the teller
+ * counter — so cash-flagged payment types are rejected here regardless of what the clerk picked
+ * in the UI.
*/
private void validateSettlementPaymentType(final Long paymentTypeId) {
final Map paymentType = jdbcTemplate
@@ -400,9 +447,15 @@ public class ClerkShareWithdrawalRequestApiResource {
savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue()
: null;
}
- checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
- savingsAccountId == null ? "No linked savings account configured"
- : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
+ // A GL settlement never touches a savings account, so this check is reported as
+ // not-applicable rather than failed for a GL request — a share account with no linked
+ // savings is a perfectly valid GL-settlement case.
+ final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
+ checks.add(glSettlementMode
+ ? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
+ : checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
+ savingsAccountId == null ? "No linked savings account configured"
+ : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
// Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id)
// when shares secure a loan against shares, so a withdrawal must not weaken that security while the
@@ -478,19 +531,31 @@ public class ClerkShareWithdrawalRequestApiResource {
}
checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail));
- boolean glMappingOk = false;
- String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
- if (savingsProductId != null && settlementPaymentTypeId != null) {
- final Map mapping = jdbcTemplate.queryForList("""
- SELECT gl.name AS glName FROM acc_product_mapping apm
- JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
- WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
- """, savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
- glMappingOk = mapping != null;
- glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
- : "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
+ // Only meaningful for a SAVINGS settlement: creditLinkedSavingsAccount() posts through a
+ // plain savings deposit command, which resolves its GL leg from the savings product's own
+ // Fund Source mapping -- the exact bug class this check exists to catch. A GL settlement
+ // never goes near that mapping at all; it credits the clerk's chosen GL account directly,
+ // so this check is reported not-applicable instead.
+ if (glSettlementMode) {
+ final Object glAccountIdObj = record.get("settlement_gl_account_id");
+ final boolean settlementGlPresent = glAccountIdObj != null;
+ checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
+ settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
+ } else {
+ boolean glMappingOk = false;
+ String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
+ if (savingsProductId != null && settlementPaymentTypeId != null) {
+ final Map mapping = jdbcTemplate.queryForList("""
+ SELECT gl.name AS glName FROM acc_product_mapping apm
+ JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
+ WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
+ """, savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
+ glMappingOk = mapping != null;
+ glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
+ : "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
+ }
+ checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
}
- checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
if (clientId != null) {
final Map kycRow = jdbcTemplate.queryForList("""
@@ -654,7 +719,13 @@ public class ClerkShareWithdrawalRequestApiResource {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
.withJson(payload.toString()).build();
- final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
+ // The command is run for what it does to the share account -- the holding it reduces and the
+ // transaction it records -- not for its accounting. Left to itself it would credit the
+ // product's fixed "Shares Reference" account, which is a stand-in for a counterparty it has
+ // no way of knowing; this register does know it, because the clerk keyed it, and posts the
+ // entry itself below against the account the money actually reached.
+ final CommandProcessingResult result = ShareAccountingSuppression
+ .withOwnShareEntries(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
// redeemShares() puts the newly-created share transaction's id under "requestedShares" in
// the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id
// instead. Reading getResourceId() here would silently look up the wrong row downstream.
@@ -662,30 +733,65 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
- // Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into
- // Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or
- // leaves share capital in this codebase passes through Sundry, and leaving redeem on its own
- // "give cash back over the counter" assumption breaks that the moment the settlement below
- // lands on a different GL than this leg did.
- if (shareTransactionId != null) {
- routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
- currentUser.getId());
+ // Settlement: within this SAME approval transaction, the redemption value is booked to
+ // wherever the clerk said it should go. Where that destination is known here -- a linked
+ // savings account, or a GL the clerk picked -- the native redeem posting's own Cr Shares
+ // Reference (Cash at Vault) leg is redirected straight onto it below, so the ledger reads as
+ // the single entry the clerk actually keyed: Dr Share Capital / Cr .
+ final String settlementMode = Optional.ofNullable((String) record.get("settlement_mode")).orElse("SAVINGS");
+ final Settlement settlement;
+ final GlSettlement glSettlement;
+ final String settlementNote;
+ // Where the redemption credit lands, and what to call it on the journal line. Resolved per
+ // mode, then applied once below.
+ final Long destinationGl;
+ final String destinationNarration;
+ if ("NONE".equals(settlementMode)) {
+ // The clerk deliberately chose to settle nowhere further: Share Sundry is the
+ // destination, not a staging post, and the value genuinely rests there until some later
+ // entry moves it. raiseSundryOnlyVoucher() below gives that entry its own standalone
+ // voucher instead of leaving it visible only as "Also posted" under some other voucher.
+ settlement = null;
+ glSettlement = null;
+ final Map noneAcct = loadShareAccountGlContext(shareAccountId);
+ destinationGl = requireShareGl(toLong(noneAcct.get("product_id")), FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
+ destinationNarration = "Share redemption held in Sundry pending settlement";
+ settlementNote = "Redemption booked to Share Sundry — no further settlement leg.";
+ } else if ("GL".equals(settlementMode)) {
+ settlement = null;
+ final Object glAccountIdObj = record.get("settlement_gl_account_id");
+ final Long settlementGlAccountId = glAccountIdObj instanceof Number ? ((Number) glAccountIdObj).longValue() : null;
+ glSettlement = resolveGlSettlement(shareTransactionId, settlementGlAccountId);
+ destinationGl = glSettlement != null ? glSettlement.glAccountId() : null;
+ destinationNarration = "Share redemption settled to GL account";
+ settlementNote = glSettlement != null
+ ? "Settled " + glSettlement.amount() + " to GL account " + glCodeOf(glSettlement.glAccountId())
+ : "Share transaction not found — GL settlement could not be booked.";
+ } else {
+ glSettlement = null;
+ settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), resolutionNumber);
+ destinationGl = settlement != null ? settlement.savingsControlGlId() : null;
+ destinationNarration = "Share redemption settled to savings";
+ settlementNote = settlement != null
+ ? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
+ : "No linked savings account on this share account — settlement must be completed manually.";
}
- // Settlement: within this SAME approval transaction, immediately credit the redemption value
- // to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly,
- // not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally
- // land on the same account the leg above credited. See creditLinkedSavingsAccount().
- final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
- final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
- ? ((Number) storedSettlementPaymentTypeId).longValue()
- : null;
- final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(),
- resolutionNumber, settlementPaymentTypeId);
+ // One posting, not two: the native redeem's Cr Cash at Vault leg is replaced by a credit to
+ // the destination itself, under the native posting's own transaction_id. The ledger then
+ // holds exactly the entry the clerk keyed -- Dr Share Capital / Cr -- instead
+ // of routing it through Sundry and back out again, which left two transaction groups and a
+ // pass-through balance that always netted to zero.
+ //
+ // Where no destination could be resolved (no linked savings account, or the share
+ // transaction could not be read back), the native Cr leg is left exactly as Fineract posted
+ // it rather than being reversed with nothing put in its place, which would leave the Dr
+ // Share Capital leg standing alone and the entry out of balance.
+ if (shareTransactionId != null && destinationGl != null) {
+ postRedemptionEntry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
+ currentUser.getId(), destinationGl, destinationNarration);
+ }
- final String settlementNote = settlement != null
- ? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
- : "No linked savings account on this share account — settlement must be completed manually.";
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
settlementNote);
@@ -696,7 +802,8 @@ public class ClerkShareWithdrawalRequestApiResource {
settlement_amount = ?, settlement_savings_transaction_id = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog,
- Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null,
+ Timestamp.from(Instant.now()),
+ settlement != null ? settlement.amount() : (glSettlement != null ? glSettlement.amount() : null),
settlement != null ? settlement.savingsTransactionId() : null, id);
if (approvedRows == 0) {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
@@ -712,7 +819,9 @@ public class ClerkShareWithdrawalRequestApiResource {
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
// redemption settled into a member's savings account left nothing on the voucher register to
// read it back from. The GL entries were always written — it is the slip that was missing.
- final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
+ final String voucherNo = "NONE".equals(settlementMode) ? raiseSundryOnlyVoucher(id, shareTransactionId, currentUser)
+ : "GL".equals(settlementMode) ? raiseGlShareWithdrawalVoucher(id, shareTransactionId, record, glSettlement, currentUser)
+ : raiseShareWithdrawalVoucher(id, shareAccountId, shareTransactionId, record, settlement, currentUser);
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
@@ -723,7 +832,9 @@ public class ClerkShareWithdrawalRequestApiResource {
if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId);
}
- if (settlement != null) {
+ if (glSettlement != null) {
+ res.addProperty("settlementAmount", glSettlement.amount());
+ } else if (settlement != null) {
res.addProperty("settlementAmount", settlement.amount());
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
} else {
@@ -754,7 +865,11 @@ public class ClerkShareWithdrawalRequestApiResource {
closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request.");
final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
.withJson(closePayload.toString()).build();
- commandsSourceWritePlatformService.logCommandSource(closeCommand);
+ // Suppressed for the same reason as the redemption above: anything this posts would go
+ // against the product's fixed "Shares Reference" account. There is nothing left for it to
+ // post in any case -- this runs only once the holding has reached zero, so the redemption
+ // that emptied it has already been booked, in full, against the account the money reached.
+ ShareAccountingSuppression.withOwnShareEntries(() -> commandsSourceWritePlatformService.logCommandSource(closeCommand));
return true;
}
@@ -771,32 +886,34 @@ public class ClerkShareWithdrawalRequestApiResource {
* savings credit have all already succeeded in this transaction, and losing the slip is worth
* less than rolling back a member's money.
*/
- private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map, ?> record,
- final Settlement settlement, final AppUser currentUser) {
+ private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Long shareTransactionId,
+ final Map, ?> record, final Settlement settlement, final AppUser currentUser) {
if (!voucherService.shouldRaiseVoucher() || settlement == null
|| settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
try {
- final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
- shareAccountId);
- final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
+ final Long glShare = redeemDebitGlOf(shareTransactionId);
+ if (glShare == null) {
+ return null;
+ }
final Long savingsAccountId = jdbcTemplate
.queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream()
.findFirst().orElse(null);
- final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId);
final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member";
- final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no"));
final String savingsAccountNo = savingsAccountId == null ? null
: jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId)
.stream().findFirst().orElse(null);
final List lines = new ArrayList<>();
- lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount()));
- lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount()));
+ lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), settlement.amount()));
+ // This leg is the member's own savings account receiving the redemption proceeds, not a
+ // second GL posting -- creditGl(glSavingsControl, ...) named the shared Savings Control GL
+ // account here, mismatched against the member's own account number on the same line.
+ lines.add(VoucherLine.creditSavings(savingsAccountId, memberName, savingsAccountNo, settlement.amount()));
// PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their
// savings account, which is the receipt series — the share account is the source, not a
@@ -807,6 +924,14 @@ public class ClerkShareWithdrawalRequestApiResource {
voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId,
settlement.savingsTransactionId());
}
+ // Every other request type that raises a voucher stores its own voucher_no back on
+ // itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
+ // find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
+ // invisible everywhere a register looks it up by its owning request, understating the
+ // branch's Payment total and the Day Register's expected cash by exactly this amount.
+ if (voucherNo != null) {
+ jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
+ }
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e);
@@ -814,7 +939,177 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
- private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
+ private record Settlement(BigDecimal amount, Long savingsTransactionId, Long savingsControlGlId) {}
+
+ private record GlSettlement(BigDecimal amount, Long glAccountId) {}
+
+ /**
+ * The GL account the native redeem command debited — the share capital side of the redemption,
+ * which is what a voucher's Dr line has to name now that the credit goes straight to its
+ * destination and no Sundry leg stands between them.
+ *
+ *
+ * Read back from what was actually posted rather than resolved from a mapping constant: this
+ * class has never posted that leg itself (only Fineract's own redeem command does), so reading it
+ * back is correct whichever GL the product's Shares Equity mapping happens to point at.
+ *
+ *
+ * Scoped to the live Dr row for this share transaction, newest first: the "SH"+id transaction_id
+ * text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
+ * reset id sequence in a dev environment can violate.
+ */
+ private Long redeemDebitGlOf(final Long shareTransactionId) {
+ if (shareTransactionId == null) {
+ return null;
+ }
+ return jdbcTemplate.queryForList("""
+ SELECT account_id FROM acc_gl_journal_entry
+ WHERE share_transaction_id = ? AND type_enum = ? AND reversed = false
+ ORDER BY id DESC LIMIT 1
+ """, Long.class, shareTransactionId, DEBIT).stream().findFirst().orElse(null);
+ }
+
+ /**
+ * Reads back the amount to settle and names the GL account the clerk picked on the CR row of the
+ * Fund Transfer — the GL counterpart to {@link #creditLinkedSavingsAccount} for a settlement that
+ * never touches a member's own account (e.g. crediting a "DD Payable" holding account, so a
+ * second, independent manual entry can later move the value out to the actual demand draft).
+ *
+ *
+ * Nothing is posted here. The caller redirects the native redeem posting's own credit leg onto
+ * this account, so the redemption reads as the one entry the clerk keyed — Dr Share Capital / Cr
+ * that GL — rather than as a Sundry hop that nets to zero.
+ *
+ *
+ * Unlike a cash settlement, nothing physical changes hands here, so the amount is not rounded —
+ * the redemption value posts to the paisa, same as the linked-savings path.
+ */
+ private GlSettlement resolveGlSettlement(final Long shareTransactionId, final Long settlementGlAccountId) {
+ if (shareTransactionId == null) {
+ return null;
+ }
+ if (settlementGlAccountId == null) {
+ throw apiError(409, "No settlement GL account was recorded on this request.");
+ }
+ final Map row = jdbcTemplate.queryForList("""
+ SELECT sat.amount AS settlementAmount FROM m_share_account_transactions sat WHERE sat.id = ?
+ """, shareTransactionId).stream().findFirst().orElse(null);
+ if (row == null) {
+ return null;
+ }
+ return new GlSettlement((BigDecimal) row.get("settlementAmount"), settlementGlAccountId);
+ }
+
+ /**
+ * The voucher for an approved GL redemption: Dr Share Capital, Cr the clerk-chosen settlement
+ * GL account — the same two accounts the redemption actually posted to, both named and coded
+ * from the ledger since both are real GL accounts here (unlike the linked-savings path, there is
+ * no member-account leg on this side at all).
+ */
+ private String raiseGlShareWithdrawalVoucher(final Long requestId, final Long shareTransactionId, final Map, ?> record,
+ final GlSettlement glSettlement, final AppUser currentUser) {
+ if (!voucherService.shouldRaiseVoucher() || glSettlement == null || glSettlement.amount() == null
+ || glSettlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
+ return null;
+ }
+ try {
+ final Long glShare = redeemDebitGlOf(shareTransactionId);
+ final Long glTarget = glSettlement.glAccountId();
+ if (glShare == null) {
+ return null;
+ }
+
+ final List lines = new ArrayList<>();
+ lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), glSettlement.amount()));
+ lines.add(VoucherLine.creditGl(glTarget, glNameOf(glTarget), glCodeOf(glTarget), glSettlement.amount()));
+
+ // PREFIX_DEBIT, not CREDIT: unlike the linked-savings path, this settlement never credits
+ // a member's own account -- the value is moving out of share capital toward an eventual
+ // payout (e.g. a second, independent manual entry crediting a "DD Payable" account out to
+ // the actual draft), which is the payment series, not a receipt.
+ final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
+ VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
+ // Every other request type that raises a voucher stores its own voucher_no back on
+ // itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
+ // find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
+ // invisible everywhere a register looks it up by its owning request.
+ if (voucherNo != null) {
+ jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
+ }
+ return voucherNo;
+ } catch (final RuntimeException e) {
+ LOG.error("Could not raise a GL voucher for share withdrawal request {}", requestId, e);
+ return null;
+ }
+ }
+
+ /**
+ * The voucher for a request that settles nowhere further than leg 1 itself (settlement mode
+ * 'NONE'): Dr Share Capital, Cr Share Sundry — exactly what {@link #postRedemptionEntry} just
+ * posted, given its own standalone voucher instead of only being visible as "Also posted" under
+ * some other request's voucher (see {@code VoucherVerificationApiResource.relatedGlEntries()}).
+ *
+ *
+ * Read back from {@code acc_gl_journal_entry} by the native transaction's own transaction_id
+ * (the same technique {@code relatedGlEntries()} uses) rather than re-resolving the Shares
+ * Equity GL directly — there is no {@code FA_SHARES_EQUITY} constant in this class because that
+ * leg has never been posted by this class itself, only by Fineract's own native redeem command;
+ * reading back what was actually posted is correct regardless of which GL that turns out to be.
+ *
+ *
+ * Capped to the 2 most recently inserted matching rows (highest id), not every row under that
+ * transaction_id: {@code "SH" + shareTransactionId} is only unique so long as
+ * {@code m_share_account_transactions.id} never repeats, which a reset/reseeded id sequence in a
+ * dev/test environment can violate — an older, unrelated redemption that happened to land on the
+ * same id would otherwise be pulled in as a second, phantom copy of this leg, doubling the total
+ * shown here even though only one pair was ever posted for this approval. This call runs
+ * immediately after {@link #postRedemptionEntry} in the same approve(), so the highest-id pair
+ * is always the one it just inserted.
+ */
+ private String raiseSundryOnlyVoucher(final Long requestId, final Long shareTransactionId, final AppUser currentUser) {
+ if (!voucherService.shouldRaiseVoucher() || shareTransactionId == null) {
+ return null;
+ }
+ try {
+ final List nativeTxnIds = jdbcTemplate.queryForList(
+ "SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?", String.class,
+ shareTransactionId);
+ if (nativeTxnIds.isEmpty()) {
+ return null;
+ }
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT je.type_enum, je.amount, ga.id AS gl_id, ga.name AS gl_name, ga.gl_code
+ FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id
+ WHERE je.transaction_id = ? AND je.reversed = false
+ ORDER BY je.id DESC
+ LIMIT 2
+ """, nativeTxnIds.get(0));
+ if (rows.isEmpty()) {
+ return null;
+ }
+ // Restore Dr-then-Cr reading order after the DESC/LIMIT above.
+ java.util.Collections.reverse(rows);
+
+ final List lines = new ArrayList<>();
+ for (final Map r : rows) {
+ final Long glId = ((Number) r.get("gl_id")).longValue();
+ final BigDecimal amount = (BigDecimal) r.get("amount");
+ final boolean isDebit = ((Number) r.get("type_enum")).intValue() == DEBIT;
+ lines.add(isDebit ? VoucherLine.debitGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount)
+ : VoucherLine.creditGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount));
+ }
+
+ final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
+ VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
+ if (voucherNo != null) {
+ jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
+ }
+ return voucherNo;
+ } catch (final RuntimeException e) {
+ LOG.error("Could not raise a Sundry-only voucher for share withdrawal request {}", requestId, e);
+ return null;
+ }
+ }
/**
* Credits the redemption value to the share account's linked savings account, if one exists.
@@ -827,14 +1122,19 @@ public class ClerkShareWithdrawalRequestApiResource {
* Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the
* member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry
* (Dr whichever GL the settlement payment type's Fund Source maps to / Cr Savings Control) is then
- * reversed in full and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used
- * to be left to the payment type's own mapping in the hope it would land on the same account
- * {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it
- * did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually
- * met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental.
+ * reversed in full , and nothing is posted in its place here: the caller redirects the redemption's own
+ * credit leg straight onto the Savings Control GL returned below, so the ledger carries one entry — Dr Share
+ * Capital / Cr Savings Control — instead of a pair routed through Sundry and back out again.
+ *
+ *
+ * The debit side used to be left to the payment type's own Fund Source mapping in the hope it would land on the
+ * account the redemption leg had credited, netting to zero; when that mapping pointed anywhere else (as it did the
+ * day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually met, leaving
+ * Sundry debited for money that was never credited to it. Naming the destination explicitly, and posting only
+ * once, removes that class of mismatch entirely.
*/
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
- final String resolutionNumber, final Long settlementPaymentTypeId) {
+ final String resolutionNumber) {
if (shareTransactionId == null) {
return null;
}
@@ -853,52 +1153,102 @@ public class ClerkShareWithdrawalRequestApiResource {
final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount");
- final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId
- : jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class,
- SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null);
-
- final JsonObject payload = new JsonObject();
- payload.addProperty("locale", "en");
- payload.addProperty("dateFormat", "dd MMMM yyyy");
- payload.addProperty("transactionDate", formatForFineract(requestedDateStr));
- payload.addProperty("transactionAmount", settlementAmount);
- if (paymentTypeId != null) {
- payload.addProperty("paymentTypeId", paymentTypeId);
- }
- payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber);
-
- final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
- .withJson(payload.toString()).build();
- final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
- final Long savingsTransactionId = result.getResourceId();
-
- // The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
- // than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
- // on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
- jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
-
- final Map acct = loadShareAccountGlContext(shareAccountId);
- 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 Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw apiError(409, "The Savings Control GL is not mapped for this savings account's product.");
}
- final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
+ // Office comes from the share account the redemption was posted against, not from a join
+ // through the savings account's client -- a savings account can be held by a group, where
+ // that join yields nothing and the insert below would fail on a null office.
+ final Long officeId = toLong(loadShareAccountGlContext(shareAccountId).get("office_id"));
+ if (officeId == null) {
+ throw apiError(409, "The share account has no office on it, so the settlement transaction cannot be booked.");
+ }
+
+ final Long savingsTransactionId = directCreditSavingsAccount(savingsAccountId, officeId, settlementAmount, requestedDateStr,
+ resolutionNumber);
+
+ return new Settlement(settlementAmount, savingsTransactionId, glSavingsControl);
+ }
+
+ /**
+ * Credits the member's savings account as a book transfer: the transaction row and the balance,
+ * written directly, with no journal entry of its own.
+ *
+ *
+ * The obvious way to do this is Fineract's own deposit command, and that is what this used to
+ * call. But that command posts its own Dr Cash at Vault / Cr Savings Control pair on the
+ * assumption that notes came over the counter, which they did not — the money is moving out of
+ * share capital inside one approval. Reversing that pair afterwards worked, yet left two dead
+ * rows in the ledger for every redemption and a Cash at Vault leg that had to be explained to
+ * anyone reading the table. Writing the transaction directly means those rows are never created
+ * at all, and the redemption's own entry — Dr Share Capital / Cr Savings Control, posted by the
+ * caller — is the only accounting the event produces.
+ *
+ *
+ * This is the same technique the disbursement register already uses for its own book transfers
+ * ({@code ClerkDisbursementRequestApiResource.directCreditSavingsAccount()}), for the same
+ * reason, and carries the same trade-off: the native command's interest recalculation, lien and
+ * overdraft checks and business events are all skipped. That is acceptable here because a
+ * redemption settlement is a plain credit of a known amount into an active account on the
+ * approval date, which is exactly the case those extras do not apply to.
+ *
+ *
+ * The passbook and statement are unaffected: both read {@code m_savings_account_transaction},
+ * which is what this writes.
+ */
+ private Long directCreditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
+ final String requestedDateStr, final String resolutionNumber) {
+ final Map acct = jdbcTemplate
+ .queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
+ .findFirst().orElse(null);
+ if (acct == null) {
+ throw apiError(409, "The linked savings account " + savingsAccountId + " no longer exists.");
+ }
+ final int statusEnum = ((Number) acct.get("status_enum")).intValue();
+ if (statusEnum != SAVINGS_STATUS_ACTIVE) {
+ throw apiError(409, "The linked savings account is not active, so the redemption cannot be settled into it.");
+ }
+ final BigDecimal currentBalance = acct.get("account_balance_derived") != null
+ ? (BigDecimal) acct.get("account_balance_derived")
+ : BigDecimal.ZERO;
+
+ final java.sql.Date sqlDate = java.sql.Date.valueOf(requestedDateStr.substring(0, 10));
+ final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final long userId = context.authenticatedUser().getId();
- final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
- final String txnId = "SHRDMSTL-" + System.nanoTime();
- final String accountNo = (String) acct.get("account_no");
- insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
- "Share redemption settled to savings | " + accountNo);
- insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
- "Share redemption settlement credited to savings | " + accountNo);
+ final KeyHolder kh = new GeneratedKeyHolder();
+ jdbcTemplate.update(con -> {
+ // transaction_type_enum 1 = deposit.
+ final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
+ + "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
+ + "running_balance_derived, is_lien_transaction, "
+ + "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
+ + "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" });
+ ps.setLong(1, savingsAccountId);
+ ps.setLong(2, officeId);
+ ps.setDate(3, sqlDate);
+ ps.setBigDecimal(4, amount);
+ ps.setDate(5, sqlDate);
+ ps.setString(6, refNo);
+ ps.setBigDecimal(7, currentBalance.add(amount));
+ ps.setLong(8, userId);
+ ps.setLong(9, userId);
+ ps.setObject(10, now);
+ ps.setObject(11, now);
+ return ps;
+ }, kh);
+ final long savingsTransactionId = Objects.requireNonNull(kh.getKey()).longValue();
- return new Settlement(settlementAmount, savingsTransactionId);
+ jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
+ + "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
+
+ LOG.info("Share redemption settled into savings account {} as transaction {} (Resolution No. {})", savingsAccountId,
+ savingsTransactionId, resolutionNumber);
+ return savingsTransactionId;
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
@@ -969,58 +1319,65 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
- // ── Sundry pass-through helpers ──────────────────────────────────────────
+ // ── The redemption's own accounting ──────────────────────────────────────
/**
- * Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
+ * Posts the redemption as one balanced entry against the account the money actually reached.
*
*
- * Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr
- * Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the
- * member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement
- * posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited
- * for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference"
- * GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be
- * left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry
- * the same way every other share-capital movement in this codebase does.
+ * Share capital is debited and the destination credited, and that is the whole entry: Dr Share
+ * Equity / Cr the linked savings account's control GL, or the GL the clerk picked, or Share Sundry
+ * where the clerk chose to settle nowhere further. No "Shares Reference" account appears anywhere
+ * in it. Fineract's own bridge would have credited that account instead — a placeholder for a
+ * counterparty it cannot know — so the command that creates the share transaction runs inside
+ * {@link ShareAccountingSuppression#withOwnShareEntries} and posts nothing of its own.
+ *
+ *
+ * A redemption charge, where the product has one, is income and is credited separately: share
+ * capital carries the full amount plus the charge, the member receives the amount, and the
+ * difference lands in Income from Fees. With no charge configured the third line is simply absent.
+ *
+ *
+ * The transaction_id is the one the native command would have used ({@code "SH" + id}), so the
+ * entry groups and reconciles exactly as every other share posting in the platform does, and
+ * anything reading a share transaction's entries by that id keeps working unchanged.
*/
- private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
- final long userId) {
+ private void postRedemptionEntry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
+ final long userId, final Long destinationGlId, final String narration) {
+ final Map txn = jdbcTemplate
+ .queryForList("SELECT amount, COALESCE(charge_amount, 0) AS charge_amount FROM m_share_account_transactions WHERE id = ?",
+ shareTransactionId)
+ .stream().findFirst().orElse(null);
+ if (txn == null) {
+ return;
+ }
+ final BigDecimal amount = (BigDecimal) txn.get("amount");
+ final BigDecimal charge = (BigDecimal) txn.get("charge_amount");
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+ return;
+ }
+
final Map acct = loadShareAccountGlContext(shareAccountId);
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 glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
- final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
-
- final Map reversedLeg = jdbcTemplate
- .queryForList(
- "SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? "
- + " AND type_enum = ? AND reversed = false",
- shareTransactionId, glReference, CREDIT)
- .stream().findFirst().orElse(null);
- if (reversedLeg == null) {
- // Nothing to redirect -- the native posting didn't credit Shares Reference at all (an
- // unexpected accounting configuration), so there is no leg here to reroute.
- return;
- }
- final BigDecimal amount = (BigDecimal) reversedLeg.get("amount");
- // Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are
- // grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr
- // Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone,
- // instead of the two reading as the one balanced entry they actually are.
- final String nativeTxnId = (String) reversedLeg.get("transaction_id");
-
- jdbcTemplate.update(
- "UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? "
- + " AND type_enum = ? AND reversed = false",
- shareTransactionId, glReference, CREDIT);
+ final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Equity");
+ final boolean hasCharge = charge != null && charge.compareTo(BigDecimal.ZERO) > 0;
+ final String nativeTxnId = SHARE_TXN_ID_PREFIX + shareTransactionId;
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
- insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode,
- "Share redemption held in Sundry pending settlement | " + accountNo);
+
+ insertEntry(officeId, glEquity, DEBIT, hasCharge ? amount.add(charge) : amount, nativeTxnId, entryDate, shareAccountId,
+ shareTransactionId, userId, now, currencyCode, "Share redemption | " + accountNo);
+ insertEntry(officeId, destinationGlId, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now,
+ currencyCode, narration + " | " + accountNo);
+ if (hasCharge) {
+ final Long glFees = requireShareGl(productId, FA_INCOME_FROM_FEES, "Income from Fees");
+ insertEntry(officeId, glFees, CREDIT, charge, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now,
+ currencyCode, "Share redemption charge | " + accountNo);
+ }
}
private Map loadShareAccountGlContext(final Long shareAccountId) {
@@ -1050,6 +1407,39 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
+ /** The GL account's own code, for the {@code accountNo} printed on a voucher's GL-side line --
+ * never the account/party this GL entry happens to relate to, which is what the "name" already
+ * carries. Null-safe so a caller can pass a possibly-missing gl account id straight through. */
+ private String glCodeOf(final Long glAccountId) {
+ if (glAccountId == null) {
+ return null;
+ }
+ try {
+ return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
+ } catch (final IncorrectResultSizeDataAccessException e) {
+ return null;
+ }
+ }
+
+ /** The GL account's own configured name (e.g. "SUNDRY CREDITORS A/C"), for the party label on a
+ * voucher's GL-side line -- fixed labels like "Share Sundry" or "Cash in Hand" used to stand in
+ * for whatever the tenant's own chart of accounts actually maps to (shares-suspense, cash at
+ * vault), so a voucher read as crediting/debiting a party that doesn't exist in the ledger at all
+ * whenever the real mapped account was named differently. Null-safe: a missing/unmapped account
+ * falls back to a neutral label rather than one of those specific fixed names, which would be
+ * actively wrong when reused for a different GL account (as glCodeOf's callers do). */
+ private String glNameOf(final Long glAccountId) {
+ if (glAccountId == null) {
+ return "GL Account";
+ }
+ try {
+ final String name = jdbcTemplate.queryForObject("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
+ return name != null ? name : "GL Account";
+ } catch (final IncorrectResultSizeDataAccessException e) {
+ return "GL Account";
+ }
+ }
+
private Long savingsControlGlOf(final Long savingsAccountId) {
final List> rows = jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
@@ -1059,19 +1449,31 @@ public class ClerkShareWithdrawalRequestApiResource {
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
+ /**
+ * {@code share_transaction_id} and {@code entity_type_enum} are written, not left null as a manual
+ * entry normally would: these rows stand in for the ones the platform's own bridge would have
+ * posted, so they have to be indistinguishable from them to everything that goes looking.
+ *
+ *
+ * The voucher builders here match on {@code share_transaction_id}; Fineract's own
+ * {@code revertShareAccountJournalEntries} matches on {@code transaction_id} and
+ * {@code entity_type_enum = SHARES}, and would silently skip a row missing that column, leaving
+ * the accounting standing after the transaction behind it had been reverted.
+ */
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) {
+ final LocalDate entryDate, final Long shareAccountId, final Long shareTransactionId, final long userId,
+ final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
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, "
+ + " entry_date, type_enum, amount, description, entity_id, entity_type_enum, share_transaction_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,?,?,?,?)",
+ + "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));
+ PRODUCT_TYPE_SHARES, shareTransactionId, userId, userId, now, now, java.sql.Date.valueOf(entryDate),
+ java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java
index 4a7b680b4..8e2891297 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java
@@ -484,7 +484,7 @@ public class ClerkWithdrawalRequestApiResource {
// The sides of the slip. A withdrawal is money leaving the member, so their account is
// debited and the till that pays it out is credited.
- addWithdrawalVoucherLines(record, savingsAccountId, amount);
+ addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
@@ -735,28 +735,61 @@ public class ClerkWithdrawalRequestApiResource {
* Records the two sides of an approved withdrawal on its voucher.
*
* Money leaves the member and is paid out over the counter, so the member's account is debited
- * and the till is credited. The withdrawal register does not carry a GL id for the till — it
- * pays out of whichever cashier's drawer serves the request — so that side is named without one.
+ * and the till is credited. The till side can't be resolved from config the way a Share or
+ * Closure withdrawal's cash leg can: which GL Fineract's native withdrawal command actually
+ * credits depends on the request's own payment type (Cash, Cheque, DD, ...) via its Fund Source
+ * mapping, which this class does not re-derive. Reading back what was actually posted for this
+ * transaction (see resolveTillGlFromSavingsTxn()) names it accurately for any payment type;
+ * "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a current
+ * account withdrawal, or a tenant with accounting off).
*
* A failure here does not fail the approval: the withdrawal is already posted and the member
* already paid. The problem is logged, and the voucher keeps its number without sides.
*/
- private void addWithdrawalVoucherLines(final Map, ?> record, final Long savingsAccountId, final BigDecimal amount) {
+ private void addWithdrawalVoucherLines(final Map, ?> record, final Long savingsAccountId, final BigDecimal amount,
+ final String savingsTxnId) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
return;
}
try {
+ final Map tillGl = resolveTillGlFromSavingsTxn(savingsTxnId);
voucherService.addLines(voucherNo,
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
(String) record.get("account_no"), amount),
- VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
+ tillGl == null ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
+ : VoucherLine.creditGl(((Number) tillGl.get("id")).longValue(), (String) tillGl.get("name"),
+ (String) tillGl.get("code"), amount)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
}
}
+ /** The GL account Fineract's native withdrawal command actually credited for this savings
+ * transaction — looked up by the {@code savings_transaction_id} FK on {@code
+ * acc_gl_journal_entry} rather than assumed, since the real posting depends on the withdrawal's
+ * own payment type. Returns null (falls back to a generic label) for a current-account
+ * withdrawal, which has no savings transaction to match, or a tenant with accounting off. */
+ private Map resolveTillGlFromSavingsTxn(final String savingsTxnId) {
+ if (savingsTxnId == null) {
+ return null;
+ }
+ final Long id;
+ try {
+ id = Long.valueOf(savingsTxnId);
+ } catch (final NumberFormatException e) {
+ return null;
+ }
+ final List> rows = jdbcTemplate.queryForList("""
+ SELECT g.id, g.name, g.gl_code AS code FROM acc_gl_journal_entry je
+ JOIN acc_gl_account g ON g.id = je.account_id
+ WHERE je.savings_transaction_id = ? AND je.type_enum = 1 AND je.reversed = false
+ LIMIT 1
+ """, id);
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
private Map, ?> loadRecord(final Long id) {
final List> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
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 599aaad8c..2c1d1e584 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
@@ -47,6 +47,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -520,7 +522,7 @@ public class DividendClaimRequestApiResource {
final Long ddPayableAccountId = ddPayableAccount();
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
- final String transactionId = "DIVDD" + claimId;
+ final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLAIM);
// 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.
@@ -571,12 +573,18 @@ public class DividendClaimRequestApiResource {
final Map debitGl = glAccountForVoucher(debitGlId);
final Map creditGl = glAccountForVoucher(creditGlId);
- voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser,
- VoucherService.PREFIX_DEBIT,
+ final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId,
+ currentUser, VoucherService.PREFIX_DEBIT,
List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount)
.withDescription(description),
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
.withDescription(description)));
+ // Every other maker-checker request that raises a voucher stores its own voucher_no back
+ // on itself, which is how "My Voucher"/"My Work" finds it -- without this, the voucher
+ // existed in m_voucher/m_voucher_line but was invisible to the manager who approved it.
+ if (voucherNo != null) {
+ jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET voucher_no = ? WHERE id = ?", voucherNo, claimId);
+ }
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for dividend claim {}", claimId, e);
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java
index b180a5365..d7c67ed28 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java
@@ -1111,19 +1111,34 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
try {
final AppUser currentUser = this.context.authenticatedUser();
final Map portfolio = this.voucherService.loanPortfolioAccount(loan.getId());
- final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue();
final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null;
final String accountNo = loan.getAccountNumber();
final String partyName = party != null ? party : (String) portfolio.get("name");
- final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount)
- : VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount);
+ // This is the borrower's own loan account leg, not a GL posting -- debitGl/creditGl(
+ // portfolioGlId, ...) named the shared Loan Portfolio GL account here, mismatched against
+ // this borrower's own loan account number on the same line.
+ final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitLoan(loan.getId(), partyName, accountNo, amount)
+ : VoucherLine.creditLoan(loan.getId(), partyName, accountNo, amount);
- // The till. The loan module does not resolve a GL for the cashier's drawer, so the line
- // names it without an id rather than guessing at one.
- final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
- : VoucherLine.debitGl(null, "Cash in Hand", null, amount);
+ // The till. Which GL the native disbursement/repayment command actually posted here
+ // depends on the transaction's own payment type (Cash, Cheque, DD, ...) via its Fund
+ // Source mapping, so it's read back from what was actually posted rather than assumed --
+ // "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a
+ // non-cash payment type routed elsewhere, or accounting off for this tenant).
+ final Map tillGl = this.voucherService.tillGlFromLoanTransaction(transactionId, isDisbursement);
+ final VoucherLine cashSide;
+ if (tillGl.isEmpty()) {
+ cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
+ : VoucherLine.debitGl(null, "Cash in Hand", null, amount);
+ } else {
+ final Long tillGlId = ((Number) tillGl.get("id")).longValue();
+ final String tillGlName = (String) tillGl.get("name");
+ final String tillGlCode = (String) tillGl.get("gl_code");
+ cashSide = isDisbursement ? VoucherLine.creditGl(tillGlId, tillGlName, tillGlCode, amount)
+ : VoucherLine.debitGl(tillGlId, tillGlName, tillGlCode, amount);
+ }
final List lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide);
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java
index d5e81a559..d2fc838bb 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java
@@ -27,6 +27,8 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -225,7 +227,11 @@ public class SavingsPooledInterestPostingService {
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
final String currency = (String) due.get(0).get("currency_code");
- final String poolReference = "SIP-" + productId + "-" + postingDate;
+ // Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
+ // and the resumed legs have to carry the same reference as the legs already posted, or one
+ // interest posting ends up split across two transaction ids.
+ final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
+ productId);
// Stage 1: the debt the accrual has been building moves out of the payable and into the
// account the payout is made from, so what is still merely owed and what is on its way to
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java
index 7030dd9b0..3d347f4ff 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java
@@ -2079,7 +2079,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Map control = this.voucherService.savingsControlAccount(account.getId());
final Map expense = this.voucherService.savingsInterestExpenseAccount(account.getId());
- final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue();
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
@@ -2100,8 +2099,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL,
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
- .withDescription("Interest posted to " + accountNo),
- VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
+ .withDescription("Interest posted to " + accountNo),
+ // The member's own savings account leg, not a GL posting -- creditGl(
+ // controlGlId, ...) named the shared Savings Control GL account here,
+ // mismatched against this account's own number on the same line.
+ VoucherLine.creditSavings(account.getId(), partyName, accountNo, amount)
.withDescription("Interest credited")));
// Without this the voucher exists but nothing ties it to the transaction it paid, so
// the register cannot show the two together and the transaction shows no voucher_no.
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java
index 66d369a66..4145533a8 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java
@@ -171,7 +171,7 @@ public class ShareAdditionRequestsApiResource {
final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
return this.jdbcTemplate.queryForList("""
SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate",
- collected_by_name AS "collectedByName", status
+ collected_by_name AS "collectedByName", status, note
FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id
""".formatted(placeholders), ids.toArray());
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java
index bf3465113..ffb0ce4ef 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java
@@ -562,8 +562,31 @@ public class ShareAccount extends AbstractPersistableCustom {
this.totalSharesPending = shares;
}
+ /**
+ * Finds this account's own instance of a transaction, after a save has replaced the caller's copy.
+ *
+ *
+ * Matched on identity where there is one. The callers run this right after {@code saveAndFlush},
+ * by which point the transaction has been assigned its id, and matching on that is exact. The
+ * field comparison below is the fallback for a transaction that has not been persisted yet.
+ *
+ *
+ * The fallback cannot stand on its own, because the fields it compares do not identify a
+ * transaction: two redemptions of the same number of shares, at the same price, on the same day
+ * are identical in every one of them. When that happened the loop returned the earlier of
+ * the two and the caller went on to treat the new transaction as that one -- posting the second
+ * redemption's journal entries under the first one's transaction id, so one share transaction
+ * appeared to carry two redemptions and its voucher showed double the money that actually moved.
+ */
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
ShareAccountTransaction returnTrans = null;
+ if (transaction.getId() != null) {
+ for (ShareAccountTransaction tran : this.shareAccountTransactions) {
+ if (transaction.getId().equals(tran.getId())) {
+ return tran;
+ }
+ }
+ }
for (ShareAccountTransaction tran : this.shareAccountTransactions) {
// A charge/fee transaction carries no share count or unit price at all (both columns are
// null), so this account's own transaction list is not uniformly non-null the way a
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 ff3afb640..d07a28c4e 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
@@ -799,6 +799,15 @@ public class ShareAccountDataSerializer {
advanceCollectionIds = extractAdvanceCollectionIds(element);
if (advanceCollectionIds.isEmpty()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull();
+ } else if (advanceCollectionIds.size() > 1) {
+ // Additional-share tranches fund from exactly one receipt for now -- combining
+ // instalments across several is the first-application Apply Collection dialog's own
+ // behaviour, not this one's, until this screen is asked to support it too.
+ baseDataValidator.reset().parameter(ShareAccountApiConstants.additionalcollectionids_paramname)
+ .value(advanceCollectionIds.size())
+ .failWithCodeAndMessage("share.tranche.single.voucher.only",
+ "Only one voucher can currently fund an additional-share tranche. " + advanceCollectionIds.size()
+ + " were submitted.");
} else {
validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator);
}
@@ -867,7 +876,7 @@ public class ShareAccountDataSerializer {
advanceCollectionId);
if (rows.isEmpty()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
- .failWithCode("share.advance.collection.not.found", "A voucher for this payment could not be found.");
+ .failWithCodeAndMessage("share.advance.collection.not.found", "A voucher for this payment could not be found.");
continue;
}
final Map collection = rows.get(0);
@@ -878,7 +887,7 @@ public class ShareAccountDataSerializer {
final Number collectionClientId = (Number) collection.get("client_id");
if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
- .failWithCode("share.advance.collection.client.mismatch",
+ .failWithCodeAndMessage("share.advance.collection.client.mismatch",
"Voucher " + label + " belongs to a different member.");
}
@@ -887,13 +896,13 @@ public class ShareAccountDataSerializer {
final String status = (String) collection.get("status");
if (!"UNAPPLIED".equals(status)) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
- .failWithCode("share.advance.collection.not.unapplied",
+ .failWithCodeAndMessage("share.advance.collection.not.unapplied",
"Voucher " + label + " is no longer available — it reads " + status + ".");
}
if (isReservedByAnotherTranche(advanceCollectionId)) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
- .failWithCode("share.advance.collection.already.reserved",
+ .failWithCodeAndMessage("share.advance.collection.already.reserved",
"Voucher " + label + " is already reserved against another pending share request.");
}
@@ -907,8 +916,21 @@ public class ShareAccountDataSerializer {
.multiply(BigDecimal.valueOf(sharesRequested));
if (receiptTotal.compareTo(trancheValue) < 0) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
- .failWithCode("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
+ .failWithCodeAndMessage("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
+ ", which does not cover the " + trancheValue + " value of the shares requested.");
+ } else if (receiptTotal.compareTo(trancheValue) > 0 && account.getSavingsAccount() == null) {
+ // An over-payment is settled by crediting the difference to the member's linked savings
+ // account (see AdditionalShareTrancheFundingService.postSharesIssued) -- with none linked,
+ // approval would have nowhere to put it. Caught here, at submit, rather than left to fail
+ // once the maker has already gone: short of a member paying the exact figure, this is
+ // refused up front instead of accepted and then unable to settle.
+ final BigDecimal excessAmount = receiptTotal.subtract(trancheValue);
+ baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
+ .failWithCodeAndMessage("share.advance.collection.excess.no.savings",
+ "The vouchers selected come to " + receiptTotal + ", which is " + excessAmount + " more than the "
+ + trancheValue + " value of the shares requested. 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.");
}
}
@@ -1212,7 +1234,12 @@ public class ShareAccountDataSerializer {
private void validateMinimumSharesAndTenure(final ShareAccount account, final ShareAccountTransaction redeemTransaction,
final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List dataValidationErrors) {
final Long minimumClientShares = account.getShareProduct().getMinimumClientShares();
- if (minimumClientShares == null || remainingShares >= minimumClientShares) {
+ // The minimum only protects an account that is staying open with a reduced holding -- it has
+ // nothing to say about one being emptied out entirely. A member redeeming every share they
+ // hold is exiting, not falling short of a floor; the account closes right behind this
+ // transaction (see closeShareAccountIfSharesZero), and there is no remaining balance left for
+ // "at least the minimum" to mean anything about.
+ if (minimumClientShares == null || remainingShares == 0 || remainingShares >= minimumClientShares) {
return;
}
final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency();
@@ -1317,17 +1344,20 @@ public class ShareAccountDataSerializer {
}
AppUser approvedUser = this.platformSecurityContext.authenticatedUser();
- // Only synthesize a redeem transaction (and its GL posting) when shares actually remain to
- // be redeemed — an account closed after every share was already withdrawn has nothing left
- // to force-redeem, and posting a zero-share/zero-value transaction just clutters the GL with
- // a meaningless ₹0.00/₹0.00 entry pair.
+ // A close with shares still outstanding used to force-redeem them right here -- one native
+ // command, no maker-checker, liquidating the member's holding and crediting cash back to the
+ // GL Fineract's own bridge names, all in the single step of closing the account. That is
+ // exactly the governed Share Withdrawal flow's job: a clerk raises the redemption, a manager
+ // approves it, and it settles to wherever the clerk actually keyed -- the member's savings, a
+ // chosen GL, or Sundry. Refusing here instead of quietly redeeming is what makes closing an
+ // account with a balance impossible to do any way other than through that flow; the frontend
+ // already hid its own "Close" button for the same reason, but the command underneath was still
+ // reachable directly and this is what actually closes that gap.
final Long remainingShares = account.getTotalApprovedShares();
if (remainingShares != null && remainingShares > 0) {
- final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate());
- ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, remainingShares, unitPrice);
- account.addAdditionalPurchasedShares(transaction);
- handleRedeemSharesChargeTransactions(account, transaction);
- actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
+ baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname).value(closedDate)
+ .failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.with.shares.outstanding");
+ throw new PlatformApiDataValidationException(dataValidationErrors);
}
account.close(closedDate, approvedUser);
return actualChanges;
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java
index 2986803f5..fcf1b502a 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java
@@ -18,20 +18,18 @@
*/
package org.apache.fineract.portfolio.shareaccounts.service;
-import com.google.gson.JsonObject;
import java.math.BigDecimal;
+import java.sql.PreparedStatement;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
-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;
@@ -41,11 +39,13 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransactio
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
/**
- * Funds one additional-share tranche — moves the member's money into the Share Sundry holding
- * account, where it waits for the tranche to be approved.
+ * Funds one additional-share tranche — records how the member is paying for it, and turns that
+ * money into share capital once the tranche is approved.
*
*
* Why this is separate from the account-level settlement resource. {@code ShareAdvanceCollectionApiResource}
@@ -55,35 +55,33 @@ import org.springframework.stereotype.Component;
* valued and recorded per transaction, and leaves that account-level state alone.
*
*
- * Where the money ends up. Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts
- * the same Dr Sundry / Cr Share Capital pair either way — that pair is Fineract's own, posted by
- * {@code CashBasedAccountingProcessorForShares} when the transaction turns APPROVED. Nothing here posts it; this only
- * gets the money into Sundry in the first place.
+ * What is posted, and when. Nothing is posted when a tranche is raised — a request that is never approved
+ * never touches anybody's money. {@code settle()} books the whole thing at approval, and the entry is the transfer the
+ * clerk actually keyed:
*
*
*
*
- * Note that Fineract's stock accounting also posts an APPLIED leg of its own for a new purchase
- * (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now. Neither route
- * here works that way, so both reverse that pair in full: on the cash route the cashier already posted exactly those
- * entries when the voucher was issued, and on the savings route the money came from the member's account rather than the
- * till. Reversing only the reference debit and keeping the stock Sundry credit would leave Sundry credited twice — once
- * by the stock leg and once by the pair posted here — so the whole pair goes, and Sundry ends up credited exactly once.
+ * Fineract's stock accounting would post an APPLIED leg of its own when the tranche is raised
+ * (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now, and an APPROVED
+ * leg out of the product's mapped Sundry account. Neither matches either route here, so neither is made for a funded
+ * tranche (see {@code ShareAccountWritePlatformServiceJpaRepositoryImpl}). They used to be posted and reversed a moment
+ * later, which left dead rows in the ledger on every tranche and a Shares Reference leg that had to be explained to
+ * anyone reading the account.
*/
@Component
@RequiredArgsConstructor
@@ -94,22 +92,21 @@ public class AdditionalShareTrancheFundingService {
private static final int DEBIT = 2;
/** acc_product_mapping.financial_account_type for share products. */
- private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
+ private static final int FA_SHARES_EQUITY = 4;
private static final int PRODUCT_TYPE_SHARES = 4;
+ /** What Fineract's own bridge prefixes a share transaction's journal entries with. */
+ private static final String SHARE_TXN_ID_PREFIX = "SH";
+
/** 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 SAVINGS_STATUS_ACTIVE = 300;
- /** m_payment_type.value seeded by changelog 0483, shared with the account-level settlement. */
- private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
-
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
- private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
/**
* Funds a freshly raised tranche according to the route recorded on it.
@@ -125,20 +122,26 @@ public class AdditionalShareTrancheFundingService {
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
reserveAdvanceCollection(account, transaction);
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
- debitSavingsIntoSundry(account, transaction);
+ validateSavingsFunding(account, transaction);
}
}
/**
- * Settles a tranche's funding once the tranche has been approved and its value released from
- * Share Sundry into share capital.
+ * Turns an approved tranche's funding into share capital, and closes the funding record behind it.
*
- * No journal entries of its own — Fineract's APPROVED posting already moved the money. This only
- * closes the funding record, so a voucher whose money is now share capital cannot be presented
- * again: until this point the receipt was merely reserved, because approval was still refusable.
+ * Posts the whole entry itself rather than leaving it to the platform, because only this knows
+ * where the money actually is. Closing the record last means a voucher whose money is now share
+ * capital cannot be presented again: until this point the receipt was merely reserved, because
+ * approval was still refusable.
*/
public void settle(final ShareAccount account, final ShareAccountTransaction transaction) {
- if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
+ final String fundingSource = transaction.getFundingSource();
+ if (fundingSource == null) {
+ return;
+ }
+ postSharesIssued(account, transaction);
+
+ if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
return;
}
// Every receipt behind the tranche, not just the lead one: a member's later instalments have
@@ -153,21 +156,170 @@ public class AdditionalShareTrancheFundingService {
}
/**
- * Unwinds the funding of a tranche that was rejected, so the member's money does not sit in
- * Share Sundry against shares that will never be issued.
+ * Issues the share capital for an approved tranche: the money leaves wherever it has been sitting
+ * and becomes equity.
*
- * Fineract's own REJECTED posting already takes the value back out of Sundry — but it credits
- * Shares Reference, the till, which is only correct for the cash route. The routes part company
- * here:
+ *
+ * The credit is always Share Capital. What the debit is depends on where the money actually is,
+ * which is the whole point of posting this here rather than letting the platform do it:
*
*
- * Cash — nothing to do. The money genuinely came in as cash, so returning it to the
- * reference account is right, and releasing the receipt's reservation lets the clerk spend the
- * voucher on another tranche or refund it at the counter.
- * Savings — the reference credit is wrong: no cash is going back over the counter.
- * It is reversed and the member's savings are credited instead, which is where the money came
- * from on submit.
+ * Savings-funded - this service moved it into Share Sundry when the tranche was
+ * submitted, so Sundry is where it is and Sundry is what clears.
+ * Voucher-funded - it never moved. It is still in whichever account the cashier banked
+ * the receipt into, read back from that receipt's own posting. The platform would have debited the
+ * product's mapped Sundry account instead, taking money out of an account that never received it
+ * and leaving the one that did holding the balance for ever.
*
+ *
+ *
+ * Receipts are drawn down in order, each capped at what it actually holds, so a tranche paid for
+ * in instalments clears each receipt for its own share. Anything the receipts do not cover falls
+ * back to Sundry, which keeps the entry balanced rather than silently posting a one-sided one.
+ *
+ *
+ * Whatever a receipt holds beyond what the tranche needed — a member overpaying the lead receipt,
+ * or naming a further instalment that turns out not to be needed at all — is credited to their
+ * linked savings account, receipt by receipt, rather than left standing: every named receipt is
+ * marked APPLIED at the end of {@code settle()} regardless of whether the drawdown below actually
+ * reached it, so any part of one that this method does not move becomes untraceable money the
+ * moment that happens — still sitting in whatever GL the cashier banked it to, but with no
+ * UNAPPLIED receipt left pointing back at it.
+ */
+ private void postSharesIssued(final ShareAccount account, final ShareAccountTransaction transaction) {
+ final BigDecimal amount = transaction.amount();
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+ return;
+ }
+ final Long productId = account.getShareProduct().getId();
+ final Long officeId = account.getOfficeId();
+ final String currencyCode = account.getCurrency().getCode();
+ final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
+ final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
+
+ final AppUser currentUser = this.context.authenticatedUser();
+ final long userId = currentUser.getId();
+ final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final LocalDate entryDate = DateUtils.getBusinessLocalDate();
+ final String txnId = SHARE_TXN_ID_PREFIX + transaction.getId();
+ final String accountNo = account.getAccountNumber();
+
+ if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(transaction.getFundingSource())) {
+ // The member pays for the shares at the moment they are issued, not when the request was
+ // raised: their balance is checked on submit but nothing is taken, so a tranche that is
+ // never approved never touches their account. One pair, Dr their savings / Cr share
+ // capital, which is exactly the transfer the clerk keyed.
+ final Long savingsAccountId = account.getSavingsAccount().getId();
+ final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
+ if (glSavingsControl == null) {
+ throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
+ "The Savings Control GL is not mapped for this savings account's product.");
+ }
+ final Long savingsTxnId = debitSavingsAccount(savingsAccountId, officeId, amount, entryDate);
+ transaction.updateSavingsDebitRequestId(savingsTxnId);
+ insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
+ currencyCode,
+ "Additional shares paid from savings | " + accountNo);
+ insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
+ currencyCode,
+ "Share capital issued for " + accountNo);
+ return;
+ }
+
+ BigDecimal remaining = amount;
+ BigDecimal excess = BigDecimal.ZERO;
+ if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
+ // Every named receipt is visited, not just enough of them to cover the tranche: one left
+ // untouched here would still be marked APPLIED at the end of settle(), so this is the only
+ // chance to account for its money before that happens.
+ for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
+ final Map coll = this.jdbcTemplate
+ .queryForList("SELECT amount, gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId)
+ .stream().findFirst().orElse(null);
+ if (coll == null) {
+ continue;
+ }
+ final BigDecimal held = (BigDecimal) coll.get("amount");
+ final BigDecimal take = held.min(remaining.max(BigDecimal.ZERO));
+ final BigDecimal leftover = held.subtract(take);
+ final Long receiptGl = receiptGlOf((String) coll.get("gl_transaction_id"), glSundry);
+ if (take.compareTo(BigDecimal.ZERO) > 0) {
+ insertEntry(officeId, receiptGl, DEBIT, take, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
+ currencyCode, "Additional shares issued | " + accountNo);
+ remaining = remaining.subtract(take);
+ }
+ if (leftover.compareTo(BigDecimal.ZERO) > 0) {
+ // This receipt held more than the tranche needed. Its own GL still has to be
+ // cleared for the leftover -- the receipt is about to read APPLIED in full -- so
+ // that leftover is drawn down here too, and handed back below as a savings credit
+ // rather than left to vanish along with the receipt's UNAPPLIED status.
+ insertEntry(officeId, receiptGl, DEBIT, leftover, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
+ currencyCode, "Excess on additional shares receipt refunded | " + accountNo);
+ excess = excess.add(leftover);
+ }
+ }
+ }
+ if (remaining.compareTo(BigDecimal.ZERO) > 0) {
+ insertEntry(officeId, glSundry, DEBIT, remaining, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
+ currencyCode,
+ "Additional shares issued from Share Sundry | " + accountNo);
+ }
+
+ insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
+ currencyCode,
+ "Share capital issued for " + accountNo);
+
+ if (excess.compareTo(BigDecimal.ZERO) > 0) {
+ // Validated at submit (see ShareAccountDataSerializer.validateAdvanceCollectionsForTranche):
+ // a tranche cannot be raised with receipts totalling more than its value unless a savings
+ // account is linked to receive the difference. Checked again here rather than trusted,
+ // since this also runs for a tranche raised before that guard existed.
+ if (account.getSavingsAccount() == null) {
+ throw new PlatformDataIntegrityException("error.msg.share.tranche.excess.no.savings",
+ "This tranche's receipts total " + excess
+ + " more than the shares cost, but no savings account is linked to credit it to.");
+ }
+ final Long savingsAccountId = account.getSavingsAccount().getId();
+ final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
+ if (glSavingsControl == null) {
+ throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
+ "The Savings Control GL is not mapped for this savings account's product.");
+ }
+ // Not recorded on transaction.savingsDebitRequestId: that field means the savings-funded
+ // route's own debit, a different transaction to a different end -- overloading it here for
+ // a cash-funded tranche's incidental credit would misname what it actually is.
+ creditSavingsAccount(savingsAccountId, officeId, excess, entryDate);
+ insertEntry(officeId, glSavingsControl, CREDIT, excess, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
+ currencyCode, "Excess on additional shares receipts credited to savings | " + accountNo);
+ }
+ }
+
+ /**
+ * The account a receipt's money is actually sitting in, read back from the posting that put it
+ * there - the credit leg of that receipt's own journal transaction. Falls back to the product's
+ * Sundry account for a receipt with no posting recorded against it.
+ */
+ private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) {
+ if (glTransactionId == null || glTransactionId.isBlank()) {
+ return fallbackGlId;
+ }
+ final List rows = this.jdbcTemplate.queryForList("""
+ SELECT account_id FROM acc_gl_journal_entry
+ WHERE transaction_id = ? AND type_enum = ? AND reversed = false
+ ORDER BY id DESC LIMIT 1
+ """, Long.class, glTransactionId, CREDIT);
+ return rows.isEmpty() ? fallbackGlId : rows.get(0);
+ }
+
+ /**
+ * Unwinds the funding of a rejected tranche.
+ *
+ *
+ * Nothing was ever debited on submit — {@link #postSharesIssued} is where the money actually
+ * moves, and that never ran, because approval is what would have called it — so there is nothing
+ * to give back. The only route with a submit-time side effect is cash, where the receipt was
+ * earmarked against this account; releasing that lets the clerk spend the voucher on another
+ * tranche or refund it at the counter. Savings has none, so it is a no-op here.
*/
public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) {
final String fundingSource = transaction.getFundingSource();
@@ -176,9 +328,9 @@ public class AdditionalShareTrancheFundingService {
}
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
releaseAdvanceCollection(transaction);
- } else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
- refundSavings(account, transaction);
}
+ // A savings-funded tranche has nothing to unwind: the member is only debited when the shares
+ // are issued, so a rejected one never took their money in the first place.
}
/**
@@ -192,85 +344,129 @@ public class AdditionalShareTrancheFundingService {
}
/**
- * Returns a rejected tranche's money to the savings account it was taken from.
- */
- private void refundSavings(final ShareAccount account, final ShareAccountTransaction transaction) {
- if (account.getSavingsAccount() == null) {
- return;
- }
- final BigDecimal amount = transaction.amount();
- if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
- return;
- }
- final Long savingsAccountId = account.getSavingsAccount().getId();
- final Long productId = account.getShareProduct().getId();
- final Long officeId = account.getOfficeId();
- final String currencyCode = account.getCurrency().getCode();
-
- final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
- if (glSavingsControl == null) {
- throw new PlatformDataIntegrityException("error.msg.share.tranche.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 = "Additional shares rejected, refunded to savings | share account " + account.getAccountNumber();
-
- // Fineract's own deposit command, so the refund reaches the member's passbook and statement
- // as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in
- // over the counter — so the cash debit is replaced by a Sundry debit below.
- final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note);
- this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
-
- // Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the
- // till, so that credit is reversed; the Sundry debit it raised alongside is correct and
- // stands.
- reverseStockReferenceCredit(transaction.getId(), productId, officeId);
-
- final AppUser currentUser = this.context.authenticatedUser();
- final long userId = currentUser.getId();
- final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
- final String txnId = "SHTRREF-" + transaction.getId();
-
- insertEntry(officeId, glSavingsControl, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
- "Rejected additional shares refunded to savings " + account.getSavingsAccountNo() + " | " + account.getAccountNumber());
- }
-
- /**
- * Reverses the Shares Reference credit that Fineract's stock REJECTED posting raised for this
- * tranche, leaving its Sundry debit standing.
- */
- private void reverseStockReferenceCredit(final Long shareTransactionId, final Long productId, final Long officeId) {
- final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
- reverseTrancheEntries(shareTransactionId, officeId, glReference, CREDIT);
- }
-
- /**
- * Reverses this tranche's own unreversed entries on one GL account and one side.
+ * Takes the tranche value out of the member's savings account as a book transfer: the transaction
+ * row and the balance, written directly, with no journal entry of its own.
*
- * Matched on {@code share_transaction_id} rather than the {@code SH} transaction_id string,
- * because the APPLIED, APPROVED and REJECTED postings all reuse that same string; narrowing to a
- * single GL and side is what keeps each reversal to the posting it is meant to undo.
+ *
+ * Fineract's withdrawal command would post its own Dr Savings Control / Cr Cash pair, and no cash
+ * leaves the branch here - the money becomes share capital. That pair used to be posted and then
+ * reversed a moment later, leaving two dead rows in the ledger on every tranche. The caller books
+ * the real pair itself; this only moves the member's own balance.
+ *
+ *
+ * The passbook and statement are unaffected: both read {@code m_savings_account_transaction},
+ * which is what this writes. Interest recalculation, lien and overdraft checks and business events
+ * are the trade-off, as on the disbursement register's own book transfers.
*/
- private void reverseTrancheEntries(final Long shareTransactionId, final Long officeId, final Long glAccountId, final int typeEnum) {
- this.jdbcTemplate.update("""
- UPDATE acc_gl_journal_entry SET reversed = true
- WHERE share_transaction_id = ? AND account_id = ? AND office_id = ? AND type_enum = ? AND reversed = false
- """, shareTransactionId, glAccountId, officeId, typeEnum);
+ private Long debitSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
+ final LocalDate entryDate) {
+ final Map acct = this.jdbcTemplate
+ .queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
+ .findFirst().orElse(null);
+ if (acct == null) {
+ throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing",
+ "The linked savings account " + savingsAccountId + " no longer exists.");
+ }
+ if (((Number) acct.get("status_enum")).intValue() != SAVINGS_STATUS_ACTIVE) {
+ throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active",
+ "The linked savings account is not active.");
+ }
+ final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived")
+ : BigDecimal.ZERO;
+ if (balance.compareTo(amount) < 0) {
+ throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
+ "The linked savings account holds " + balance + ", which is less than the " + amount + " these shares cost.");
+ }
+
+ final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate);
+ final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
+ final long userId = this.context.authenticatedUser().getId();
+
+ final KeyHolder kh = new GeneratedKeyHolder();
+ this.jdbcTemplate.update(con -> {
+ // transaction_type_enum 2 = withdrawal.
+ final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
+ + "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
+ + "running_balance_derived, is_lien_transaction, "
+ + "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
+ + "VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" });
+ ps.setLong(1, savingsAccountId);
+ ps.setLong(2, officeId);
+ ps.setDate(3, sqlDate);
+ ps.setBigDecimal(4, amount);
+ ps.setDate(5, sqlDate);
+ ps.setString(6, refNo);
+ ps.setBigDecimal(7, balance.subtract(amount));
+ ps.setLong(8, userId);
+ ps.setLong(9, userId);
+ ps.setObject(10, now);
+ ps.setObject(11, now);
+ return ps;
+ }, kh);
+
+ this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
+ + "total_withdrawals_derived = total_withdrawals_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
+ return Objects.requireNonNull(kh.getKey()).longValue();
}
- 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", resolveSettlementPaymentTypeId());
- payload.addProperty("note", note);
+ /**
+ * Credits the member's savings account as a book transfer, with no journal entry of its own — the
+ * counterpart to {@link #debitSavingsAccount} for the one case that runs the other way: a cash-funded
+ * tranche whose receipts added up to more than it cost, where the difference has nowhere else to go
+ * but back to the member.
+ *
+ *
+ * Written directly for the same reason as the debit side: Fineract's deposit command would post its
+ * own Dr Cash / Cr Savings Control pair, and no cash is arriving at the counter here — the money was
+ * already in Share Sundry (or wherever the receipt's own GL was), and this is only moving the
+ * member's own balance to reflect it. No balance/status checks beyond existence: crediting an
+ * account can never leave it short of anything, unlike a debit.
+ */
+ private Long creditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
+ final LocalDate entryDate) {
+ final Map acct = this.jdbcTemplate
+ .queryForList("SELECT account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
+ .findFirst().orElse(null);
+ if (acct == null) {
+ throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing",
+ "The linked savings account " + savingsAccountId + " no longer exists.");
+ }
+ final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived")
+ : BigDecimal.ZERO;
- final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build();
- final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
- return result.getResourceId();
+ final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate);
+ final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
+ final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
+ final long userId = this.context.authenticatedUser().getId();
+
+ final KeyHolder kh = new GeneratedKeyHolder();
+ this.jdbcTemplate.update(con -> {
+ // transaction_type_enum 1 = deposit.
+ final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
+ + "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
+ + "running_balance_derived, is_lien_transaction, "
+ + "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
+ + "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" });
+ ps.setLong(1, savingsAccountId);
+ ps.setLong(2, officeId);
+ ps.setDate(3, sqlDate);
+ ps.setBigDecimal(4, amount);
+ ps.setDate(5, sqlDate);
+ ps.setString(6, refNo);
+ ps.setBigDecimal(7, balance.add(amount));
+ ps.setLong(8, userId);
+ ps.setLong(9, userId);
+ ps.setObject(10, now);
+ ps.setObject(11, now);
+ return ps;
+ }, kh);
+
+ this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
+ + "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
+ return Objects.requireNonNull(kh.getKey()).longValue();
}
/**
@@ -288,12 +484,10 @@ public class AdditionalShareTrancheFundingService {
"A cash-funded share request must name the voucher the money was received on.");
}
- // Fineract's stock APPLIED posting raises Dr Cash at Vault / Cr Share Sundry for a new
- // purchase. On this route the cashier already posted exactly that pair when the voucher was
- // issued, so leaving it would count the member's money twice — Sundry credited 200 against a
- // 100 receipt, and the till debited twice for cash that came in once. Both legs are reversed:
- // submitting a cash-funded tranche moves no money, it only earmarks the receipt.
- reverseStockAppliedPair(transaction.getId(), account.getShareProduct().getId(), account.getOfficeId());
+ // No accounting at all on this route. The cashier already posted the money when the voucher
+ // was issued, and submitting a cash-funded tranche moves none of it -- it only earmarks that
+ // receipt for this account. Fineract's own APPLIED posting, which would have booked the same
+ // money a second time, is not made for a funded tranche (see applyAddtionalShares).
for (final Long collectionId : collectionIds) {
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(),
@@ -304,7 +498,7 @@ public class AdditionalShareTrancheFundingService {
/**
* Savings route: debits the member and credits Share Sundry.
*/
- private void debitSavingsIntoSundry(final ShareAccount account, final ShareAccountTransaction transaction) {
+ private void validateSavingsFunding(final ShareAccount account, final ShareAccountTransaction transaction) {
if (account.getSavingsAccount() == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.savings",
"This share account has no linked savings account to debit.");
@@ -327,85 +521,10 @@ public class AdditionalShareTrancheFundingService {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
"Savings account " + savings.get("account_no") + " does not hold enough to cover this share request.");
}
-
- final Long productId = account.getShareProduct().getId();
- final Long officeId = account.getOfficeId();
- final String currencyCode = account.getCurrency().getCode();
- final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
- final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
- if (glSavingsControl == null) {
+ if (savingsControlGlOf(savingsAccountId) == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.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 = "Additional shares funded from savings | share account " + account.getAccountNumber();
-
- // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
- // pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch.
- final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note);
- this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
-
- // Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry,
- // assuming cash over the counter. BOTH legs are reversed, not just the reference debit:
- // leaving its Sundry credit standing and then posting the savings pair below would credit
- // Sundry twice — 200 held against a 100 debit — and the surplus would never clear.
- reverseStockAppliedPair(transaction.getId(), productId, officeId);
-
- final AppUser currentUser = this.context.authenticatedUser();
- final long userId = currentUser.getId();
- final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
- final String txnId = "SHTRSAV-" + transaction.getId();
-
- insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
- "Additional shares debited from savings " + savings.get("account_no") + " | " + account.getAccountNumber());
- insertEntry(officeId, glSundry, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
- "Additional shares held in Share Sundry pending approval | " + account.getAccountNumber());
-
- transaction.updateSavingsDebitRequestId(savingsTxnId);
- }
-
- /**
- * Reverses the Shares Reference debit that Fineract's stock APPLIED posting raised for this
- * tranche, leaving its Sundry credit standing.
- *
- * Scoped to this tranche's own entries and to those two GL accounts and sides specifically, so
- * the Sundry-to-Share-Capital pair raised later at approval is never caught by it.
- */
- private void reverseStockAppliedPair(final Long shareTransactionId, final Long productId, final Long officeId) {
- reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"), DEBIT);
- reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"),
- CREDIT);
- }
-
- 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();
- }
-
- 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.share.tranche.no.payment.type",
- "No payment types are configured. Add one before funding shares from savings.");
- }
- return toLong(fallback.get(0).get("id"));
}
private Map loadSavingsAccount(final Long savingsAccountId) {
@@ -440,19 +559,28 @@ public class AdditionalShareTrancheFundingService {
}
}
+ /**
+ * {@code share_transaction_id} and {@code entity_type_enum} are written, not left null as a manual
+ * entry normally would: these rows stand in for the ones the platform would have posted under the
+ * same {@code SH} transaction id, so they have to be indistinguishable from them to everything
+ * that goes looking. Fineract's own {@code revertShareAccountJournalEntries} matches on the
+ * transaction id and {@code entity_type_enum = SHARES}, and would silently skip a row
+ * missing that column, leaving the accounting standing after the tranche behind it was reverted.
+ */
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) {
+ final LocalDate entryDate, final Long shareAccountId, final Long shareTransactionId, final long userId,
+ final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
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, "
+ + " entry_date, type_enum, amount, description, entity_id, entity_type_enum, share_transaction_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,?,?,?,?)",
+ + "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));
+ PRODUCT_TYPE_SHARES, shareTransactionId, userId, userId, now, now, java.sql.Date.valueOf(entryDate),
+ java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
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 e2ff9bf38..d0fcd2409 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
@@ -24,6 +24,8 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -134,7 +136,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
final AppUser currentUser = this.context.authenticatedUser();
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
- final String transactionId = "DIVFORFEIT" + lockId;
+ final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT);
// 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.
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 37410ae78..b6d4c3b73 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
@@ -178,13 +178,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
if (!changes.isEmpty()) {
this.shareAccountRepository.save(account);
}
- // since we are reverting all journal entries we need to add journal
- // entries for application request
+ // Modifying a pending application replaces its purchase transaction(s) with new ones of
+ // the same shape, and only ever runs before the account has been approved -- at which
+ // point nothing has been posted for it in the first place (see createShareAccount: no
+ // cash has been collected yet, so there is nothing to post until
+ // ShareAdvanceCollectionApiResource settles the real receipt, which requires the account
+ // to already be Active). revertShareAccountJournalEntries is still called as a safety net
+ // for any legacy account that predates that change and does carry a native posting for
+ // its original transaction; it is a no-op where there is nothing to find. What it must
+ // NOT do is post a fresh entry for the replacement transaction, which this used to do
+ // unconditionally -- booking Dr Shares Reference (i.e. "cash received") for an
+ // application nobody has paid for yet.
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() //
@@ -217,9 +224,17 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId());
Set transactions = new HashSet<>();
transactions.add(transaction);
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- // After the stock APPLIED posting, not before: the savings route reverses that
- // posting's Shares Reference debit, which has to exist to be reversed.
+ // The stock APPLIED posting assumes cash came over the counter for these shares,
+ // and books Dr Shares Reference / Cr Share Sundry. A tranche funded through one of
+ // this fork's own routes did not arrive that way: the cash route's money was
+ // already taken at the till against a voucher, and the savings route debits the
+ // member's own account. Both used to let this post and then reverse it a moment
+ // later, leaving two dead rows behind on every tranche. Not posting it is the same
+ // outcome without the wreckage -- fund() below books what actually happened.
+ if (transaction.getFundingSource() == null) {
+ this.journalEntryWritePlatformService
+ .createJournalEntriesForShares(populateJournalEntries(account, transactions));
+ }
this.trancheFundingService.fund(account, transaction);
}
}
@@ -297,15 +312,12 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
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));
+ // Rejecting an application only ever happens before the account has been approved, at
+ // which point nothing has been posted for it -- createShareAccount posts nothing, and the
+ // real receipt is not booked until ShareAdvanceCollectionApiResource settles it, which
+ // requires the account to already be Active. Posting a REJECTED entry here used to credit
+ // Shares Reference (i.e. "cash returned") for cash that was never collected in the first
+ // place, because this application never reached the stage where any was.
return new CommandProcessingResultBuilder() //
.withCommandId(jsonCommand.commandId()) //
.withEntityId(accountId) //
@@ -412,9 +424,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
transactions.add(transaction);
totalSubscribedShares += transaction.getTotalShares();
}
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- // The APPROVED posting above has released the money from Share Sundry into
- // share capital, so the funding behind it can now be closed off.
+ // The stock APPROVED posting releases the money into share capital out of the
+ // product's mapped Share Sundry account, whatever the money actually did. For a
+ // tranche funded through this fork's own routes that account is not always where
+ // it is: a voucher-funded one is sitting wherever the cashier banked the receipt.
+ // settle() books those itself, against the account the money is really in.
+ final Set stockPosted = new HashSet<>();
+ for (ShareAccountTransaction transaction : transactions) {
+ if (transaction.getFundingSource() == null) {
+ stockPosted.add(transaction);
+ }
+ }
+ if (!stockPosted.isEmpty()) {
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted));
+ }
for (ShareAccountTransaction transaction : transactions) {
this.trancheFundingService.settle(account, transaction);
}
@@ -451,9 +474,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
ShareAccountTransaction transaction = account.retrievePurchasedShares(id);
transactions.add(transaction);
}
- this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
- // After the stock REJECTED posting, whose Shares Reference credit the savings
- // route reverses in favour of refunding the member.
+ // The stock REJECTED posting unwinds the stock APPLIED one. A tranche funded
+ // through this fork's own routes never had that APPLIED posting made (see
+ // applyAddtionalShares), so posting its reversal here would book the undoing of
+ // something that was never booked. unwind() below returns the money the way it
+ // actually came in.
+ final Set stockPosted = new HashSet<>();
+ for (ShareAccountTransaction transaction : transactions) {
+ if (transaction.getFundingSource() == null) {
+ stockPosted.add(transaction);
+ }
+ }
+ if (!stockPosted.isEmpty()) {
+ this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted));
+ }
for (ShareAccountTransaction transaction : transactions) {
this.trancheFundingService.unwind(account, transaction);
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java
index 7a9ac3d05..f35f10d21 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java
@@ -31,6 +31,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
+import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
+import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
@@ -304,7 +306,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
// back-dating it to 31 March files it months before it happened, where nobody reviewing the
// approval would find it. The financial year it belongs to is carried in the description.
postJournalEntryPair(distributableAccountId, settledAccountId, remainder, currencyCodeOfProduct(period.get("product_id")),
- "DIVCLOSE" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()),
+ LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLOSED),
+ java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()),
"FY " + financialYear + " undistributed profit closed off after dividend approval — declaration #"
+ declaration.getId());
}
@@ -406,7 +409,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
- final String transactionId = "DIVROUND" + dividendPayOutId;
+ final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_ROUNDING);
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice
// that will never be paid to a member, because no member's share divided into it evenly.
@@ -555,7 +558,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final AppUser currentUser = this.context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
- final String transactionId = "DIVDECL" + dividendPayOutId;
+ final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_DECLARED);
// The financial year is stamped into every dividend journal entry so the ledger itself can
// answer "how much of FY 2026-27's dividend is still unclaimed" — the GL accounts are shared
// across years, and without the year on the line the balances only ever give a running total.
@@ -602,7 +605,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
+ " (Reserve released to members)";
postJournalEntryPair(reserveAccountId, payableDividendsAccountId, amount, product.getCurrency().getCode(),
- "DIVAPPR" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description);
+ LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_APPROVED),
+ java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description);
}
/**
@@ -712,7 +716,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final String forfeitedYear = financialYearOf(((java.sql.Date) lock.get("dividend_period_end_date")).toLocalDate());
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, amount, (String) lock.get("currency_code"),
- "DIVFORF" + lockId, entryDate,
+ LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT), entryDate,
"FY " + forfeitedYear + " dividend unclaimed for " + CLAIMABLE_FINANCIAL_YEARS
+ " years — forfeited to profit on approval of FY " + financialYearOf(periodEndDate));
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java
index 5a272502f..3309b249c 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java
@@ -279,7 +279,8 @@ public class SponsorDDApiResource {
request.getChequeNumber(),
request.getChequeBankName(),
request.getCashierDepositRequestId(),
- request.getDenominationData()
+ request.getDenominationData(),
+ request.getGlAccountId()
));
}
@@ -673,6 +674,9 @@ public class SponsorDDApiResource {
// that received this cash, credited to the DD Cash Suspense GL account.
private Long cashierDepositRequestId;
private String denominationData;
+ // GL only — the GL account the clerk chose to debit directly, instead of a member's own
+ // account, a cheque, or cash.
+ private Long glAccountId;
}
@lombok.Data
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java
index 498e8fc05..1e1315337 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java
@@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService {
String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch,
String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate,
String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName,
- Long cashierDepositRequestId, String denominationData);
+ Long cashierDepositRequestId, String denominationData, Long glAccountId);
/**
* Manager/head-cashier (checker) step: performs the real debit/credit GL posting
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java
index d8febf463..590378db0 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java
@@ -60,6 +60,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private static final String METHOD_ACCOUNT_DEBIT = "ACCOUNT_DEBIT";
private static final String METHOD_CHEQUE = "CHEQUE";
private static final String METHOD_CASH = "CASH";
+ // A clerk debits a GL account directly (an institutional/holding account) instead of a member's
+ // own account, a cheque, or cash over the counter -- no client, no balance check, no walk-in
+ // purchaser details.
+ private static final String METHOD_GL = "GL";
/** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */
private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST";
@@ -101,7 +105,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount,
final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId,
final Long currentAccountId, final String chequeNumber, final String chequeBankName,
- final Long cashierDepositRequestId, final String denominationData) {
+ final Long cashierDepositRequestId, final String denominationData, final Long glAccountId) {
final AppUser currentUser = this.context.authenticatedUser();
@@ -177,6 +181,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, receiptComments, null);
cashReceiptVoucherPending = true;
}
+ } else if (METHOD_GL.equals(paymentMethod)) {
+ // No client, no walk-in purchaser -- an institutional draft funded straight from a GL
+ // account. Debiting the same account the draft is credited to (DD Payable) would be a
+ // self-cancelling entry with no real movement, the same bug class the Share GL-settlement
+ // check exists to catch.
+ if (glAccountId == null) {
+ throw new IllegalArgumentException("GL Account is required for a GL-funded DD issuance.");
+ }
+ final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue();
+ if (glAccountId.equals(ddPayableGlAccountId)) {
+ final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId);
+ throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account ("
+ + ddPayable.get("gl_code") + ") this draft is credited to — pick a different GL account to debit.");
+ }
} else {
validateClientExists(clientId);
if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account");
@@ -200,8 +218,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, "
+ "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, "
+ "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, "
- + "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) "
- + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ + "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data, gl_account_id) "
+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, leafletId);
if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT);
@@ -228,6 +246,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT);
if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT);
ps.setString(25, denominationData);
+ if (glAccountId != null) ps.setLong(26, glAccountId); else ps.setNull(26, java.sql.Types.BIGINT);
return ps;
}, kh);
@@ -410,7 +429,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ").");
}
final List> rows = this.jdbcTemplate.queryForList(
- "SELECT id, status, gl_account_id, amount, txn_id FROM m_cashier_deposit_request WHERE id = ?", cashierDepositRequestId);
+ "SELECT d.id, d.status, d.gl_account_id, d.amount, d.txn_id, d.share_account_id, cv.code_value AS entry_type"
+ + " FROM m_cashier_deposit_request d LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id WHERE d.id = ?",
+ cashierDepositRequestId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Cashier deposit request not found: " + cashierDepositRequestId);
}
@@ -419,6 +440,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
throw new IllegalStateException(
"The referenced cashier deposit request is not yet approved by a manager. Current status: " + deposit.get("status"));
}
+ if (deposit.get("share_account_id") != null) {
+ throw new IllegalStateException("This cashier deposit request has already been used for a share application.");
+ }
+ // The counter tags what a GL receipt was actually collected for at save time -- a code value
+ // under GLEntryType (Admin > System > Manage Codes). See
+ // 0565_convert_entry_type_to_code_value.xml. "General" and a missing tag (rows predating the
+ // column) are still usable; only an explicit, different purpose blocks it here.
+ final Object entryType = deposit.get("entry_type");
+ if (entryType != null && !"General".equals(entryType) && !"DD Issue".equals(entryType)) {
+ throw new IllegalStateException(
+ "This cashier deposit request was recorded as " + entryType + ", not a Demand Draft issuance. Ask the cashier to "
+ + "correct the entry type, or select a different deposit request.");
+ }
final Object depositGlAccountObj = deposit.get("gl_account_id");
final Long depositGlAccountId = depositGlAccountObj != null ? ((Number) depositGlAccountObj).longValue() : null;
if (!cashSuspenseGlAccountId.equals(depositGlAccountId)) {
@@ -441,8 +475,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private Long findJournalEntryIdForCashierDeposit(final Map depositRequest, final Long cashSuspenseGlAccountId) {
final String txnId = (String) depositRequest.get("txn_id");
if (txnId == null) return null;
+ // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
+ // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
+ // stale, unrelated journal entry.
final List> rows = this.jdbcTemplate.queryForList(
- "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? LIMIT 1", txnId, cashSuspenseGlAccountId);
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? AND reversed = false"
+ + " ORDER BY id DESC LIMIT 1", txnId, cashSuspenseGlAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
}
@@ -534,6 +572,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
}
} else if (METHOD_CHEQUE.equals(paymentMethod)) {
drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue();
+ } else if (METHOD_GL.equals(paymentMethod)) {
+ // The clerk's own chosen GL account -- no withdrawal command, no linked member account;
+ // submitIssueRequest() already confirmed it exists and differs from DD Payable.
+ drGlAccountId = ((Number) request.get("gl_account_id")).longValue();
} else if (METHOD_DIVIDEND.equals(paymentMethod)) {
// Nothing is collected from the member — the society already owes them this money — so
// the draft is funded by settling the Dividend Payable liability instead of debiting a
@@ -1237,8 +1279,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
if (!walkinPurchaserAadhaar.replaceAll("\\s", "").matches("\\d{12}")) {
throw new IllegalArgumentException("Aadhaar Number must be exactly 12 digits.");
}
+ } else if (METHOD_GL.equals(paymentMethod)) {
+ if (clientId != null) {
+ throw new IllegalArgumentException("Do not provide a Client ID for a GL-funded DD issuance — it is not raised against a member.");
+ }
+ if (savingsAccountId != null || currentAccountId != null || (chequeNumber != null && !chequeNumber.isBlank())) {
+ throw new IllegalArgumentException("Do not provide an account or cheque details when funding a draft from a GL account.");
+ }
} else {
- throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, or CASH.");
+ throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, CASH, or GL.");
}
}
@@ -1325,7 +1374,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
* Payable entry, posted via postGlOffsetEntry() right after this call, is visible.
*/
private void markWithdrawalGlEntriesReversed(final String transactionId) {
- this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", transactionId);
+ // Guarded by reversed = false: if transaction_id text is ever reused, this can only flip
+ // rows that are currently live, never re-touch an already-reversed unrelated transaction.
+ this.jdbcTemplate.update(
+ "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
+ transactionId);
}
/** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */
@@ -1374,8 +1427,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
.withDescription("Held pending DD approval"));
- this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId,
- currentUser, VoucherService.PREFIX_CREDIT, lines);
+ // Every other maker-checker request that raises a voucher stores its own voucher_no back
+ // on itself, which is how "My Voucher"/"My Work" finds it. A DD request can raise up to
+ // three vouchers across its life (this intake receipt, an approval issuance, or a
+ // rejection refund), so the column holds whichever was raised most recently -- still far
+ // better than the total blank it was before, and this is the one that exists on every DD
+ // paid in cash, receipt or not.
+ final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE,
+ issueRequestId, currentUser, VoucherService.PREFIX_CREDIT, lines);
+ if (voucherNo != null) {
+ this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
+ }
} catch (final RuntimeException e) {
log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1406,8 +1468,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
.withDescription("Cash refunded to purchaser"));
- this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
- currentUser, VoucherService.PREFIX_DEBIT, lines);
+ // See the same lines in raiseCashReceiptVoucher() above for why this is required.
+ final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
+ issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
+ if (voucherNo != null) {
+ this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
+ }
} catch (final RuntimeException e) {
log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1467,8 +1533,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.withDescription("GST on DD charges"));
}
- this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
- currentUser, VoucherService.PREFIX_DEBIT, lines);
+ // See the same lines in raiseCashReceiptVoucher() above for why this is required.
+ final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
+ issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
+ if (voucherNo != null) {
+ this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
+ }
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1592,14 +1662,21 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String transactionId = result != null ? result.getTransactionId() : null;
if (transactionId == null) return null;
+ // transactionId was just minted by the createJournalEntry command above, in this same
+ // method invocation - but scope every read/write below to the single most recently
+ // posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
+ // being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
if (linkedAccountId != null) {
this.jdbcTemplate.update(
- "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE transaction_id = ? AND type_enum = 2",
+ "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ("
+ + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ + " ORDER BY id DESC LIMIT 1)",
linkedAccountId, transactionId);
}
final List> rows = this.jdbcTemplate.queryForList(
- "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? LIMIT 1", transactionId);
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND reversed = false"
+ + " ORDER BY id DESC LIMIT 1", transactionId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java
index cf305c1e9..91a0947b6 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java
@@ -626,10 +626,16 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, "
+ "cdr.dd_face_value, cdr.dd_charges, cdr.dd_gst_percentage, cdr.dd_gst_amount "
+ "from m_cashier_deposit_request cdr "
+ + "left join m_code_value cv on cv.id = cdr.entry_type_cv_id "
+ "where cdr.status = 'APPROVED' "
+ "and cdr.gl_account_id = (select cash_suspense_gl_account_id from m_sponsor_dd_gl_mapping limit 1) "
+ "and cdr.id not in (select cashier_deposit_request_id from m_sponsor_dd_issue_request "
+ "where cashier_deposit_request_id is not null) "
+ // Already spoken for by a share application, or tagged for something else at save
+ // time -- kept out of the picker rather than only rejected at submit, so the counter
+ // never even offers the wrong voucher. See 0565_convert_entry_type_to_code_value.xml.
+ + "and cdr.share_account_id is null "
+ + "and (cv.code_value IS NULL OR cv.code_value IN ('General', 'DD Issue')) "
+ "order by cdr.actioned_at desc";
return this.jdbcTemplate.query(sql, (rs, rowNum) -> new SponsorDDCashierDepositOptionData(
rs.getLong("id"),
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java
index ae91fc825..75aa18bf7 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java
@@ -748,9 +748,22 @@ public class VoucherVerificationApiResource {
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
+ UNION ALL
+ SELECT voucher_no, manager_id, manager_name, actioned_at
+ FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
+ UNION ALL
+ SELECT voucher_no, manager_id, manager_name, actioned_at
+ FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
+ UNION ALL
+ -- This table only ever stored the checker's id, never a name snapshot the
+ -- way every other register here does, so approver_name comes back null and
+ -- the outer query's COALESCE to the verification row's name is what fills it
+ -- in on this one register.
+ SELECT voucher_no, checker_id, CAST(NULL AS VARCHAR), checker_date
+ FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
),
-- Who submitted the request the voucher belongs to.
--
@@ -766,8 +779,14 @@ public class VoucherVerificationApiResource {
SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
+ UNION ALL
+ SELECT voucher_no, clerk_id, status FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
+ UNION ALL
+ SELECT voucher_no, clerk_id, status FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
+ UNION ALL
+ SELECT voucher_no, maker_id, status FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
)
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
v.created_by_username, o.name AS branch_name,
@@ -917,6 +936,26 @@ public class VoucherVerificationApiResource {
}
}
+ /**
+ * The other side of a split transaction — GL entries a native platform command posted on its own,
+ * outside this voucher, for the same underlying event.
+ *
+ * Nothing splits that way today, so this returns empty for every voucher. Share redemption used to:
+ * approval posted Dr Share Capital / Cr Share Sundry through the native {@code redeemshares}
+ * command, and only the settlement that followed (Dr Sundry / Cr Savings Control) became a
+ * voucher, so a reader saw half an entry and no sign of where the money came from. A redemption
+ * now books as the single entry the clerk actually keyed — Dr Share Capital straight to its
+ * destination — and the voucher carries both of its sides, so there is no longer a second half to
+ * show. Returning those same rows here would print this voucher's own lines a second time under a
+ * banner saying they are not part of it.
+ *
+ * Kept rather than deleted because it is the seam a genuinely split transaction would hook into:
+ * one {@code if} branch keyed on referenceType, not a new endpoint or a new field on the response.
+ */
+ private List> relatedGlEntries(final String referenceType, final Object referenceId) {
+ return List.of();
+ }
+
// ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ────────────────────────
// A query parameter for the same reason as verify: a formatted voucher number contains
// slashes, which the strict firewall rejects even encoded, so it cannot be a path segment.
@@ -1000,6 +1039,7 @@ public class VoucherVerificationApiResource {
// existed, or repaired by hand in the database, should show as unbalanced rather than be
// presented as a valid slip.
out.put("balanced", totalDebit.compareTo(totalCredit) == 0);
+ out.put("relatedLines", relatedGlEntries((String) h.get("reference_type"), h.get("reference_id")));
return gson.toJson(out);
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java
index d20398465..0fbbf4813 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java
@@ -326,6 +326,8 @@ public class VoucherService {
final Map party = savingsPartyForTransaction(savingsTxnId);
final String partyName = (String) party.get("party_name");
final String accountNo = (String) party.get("account_no");
+ final Long savingsAccountId = party.get("savings_account_id") == null ? null
+ : ((Number) party.get("savings_account_id")).longValue();
final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue();
final List lines = new ArrayList<>();
@@ -335,13 +337,24 @@ public class VoucherService {
// The member's own side is named after the member; every other leg is named after
// its GL account, which is what a voucher shows for an income or expense head.
final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId);
- final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name");
- final String ref = isMemberSide ? accountNo : (String) row.get("gl_code");
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT.
final int typeEnum = ((Number) row.get("type_enum")).intValue();
- VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount)
- : VoucherLine.creditGl(glId, name, ref, amount);
+ final boolean isDebit = typeEnum == 2;
+
+ final VoucherLine line;
+ if (isMemberSide) {
+ // This is the member's own savings account leg, not a GL posting -- pairing the
+ // shared Savings Control GL id with the member's own account number on a debitGl/
+ // creditGl line mismatched the two; debitSavings/creditSavings name the account
+ // this actually is.
+ line = isDebit ? VoucherLine.debitSavings(savingsAccountId, partyName, accountNo, amount)
+ : VoucherLine.creditSavings(savingsAccountId, partyName, accountNo, amount);
+ } else {
+ final String name = (String) row.get("gl_name");
+ final String ref = (String) row.get("gl_code");
+ line = isDebit ? VoucherLine.debitGl(glId, name, ref, amount) : VoucherLine.creditGl(glId, name, ref, amount);
+ }
final String description = (String) row.get("description");
lines.add(description == null ? line : line.withDescription(description));
}
@@ -370,6 +383,7 @@ public class VoucherService {
private Map savingsPartyForTransaction(final Long savingsTxnId) {
return jdbcTemplate.queryForList("""
SELECT COALESCE(c.display_name, g.display_name) AS party_name,
+ sa.id AS savings_account_id,
sa.account_no,
apm.gl_account_id AS control_gl_id
FROM m_savings_account_transaction t
@@ -536,6 +550,29 @@ public class VoucherService {
.orElse(Map.of());
}
+ /**
+ * The GL account Fineract's native loan disbursement/repayment command actually posted the cash
+ * leg to, for a voucher's till side — looked up by the {@code loan_transaction_id} FK on {@code
+ * acc_gl_journal_entry} rather than assumed, since which GL that command credits (a disbursement)
+ * or debits (a repayment) depends on the transaction's own payment type via its Fund Source
+ * mapping, not a single fixed cash account. Empty rather than absent if the transaction posted no
+ * matching leg (a non-cash payment type routed elsewhere, or accounting off for this tenant) —
+ * callers show a generic label in that case rather than a wrong or missing one.
+ */
+ public Map tillGlFromLoanTransaction(final Long loanTransactionId, final boolean wantCreditLeg) {
+ if (loanTransactionId == null) {
+ return Map.of();
+ }
+ // type_enum: 1 = credit, 2 = debit.
+ final int typeEnum = wantCreditLeg ? 1 : 2;
+ return jdbcTemplate.queryForList("""
+ SELECT g.id, g.name, g.gl_code FROM acc_gl_journal_entry je
+ JOIN acc_gl_account g ON g.id = je.account_id
+ WHERE je.loan_transaction_id = ? AND je.type_enum = ? AND je.reversed = false
+ LIMIT 1
+ """, loanTransactionId, typeEnum).stream().findFirst().orElse(Map.of());
+ }
+
/**
* The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or
* one whose accounts were never recorded — callers show what they have rather than treating an
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
index 464e245d9..4c0994bd0 100644
--- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
@@ -102,4 +102,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml
new file mode 100644
index 000000000..5136078b7
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml
new file mode 100644
index 000000000..3af1550ea
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml
new file mode 100644
index 000000000..b67b5cdb6
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml
new file mode 100644
index 000000000..a3a3ca18f
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0563_ledger_txn_sequence.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0563_ledger_txn_sequence.xml
new file mode 100644
index 000000000..e89250971
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0563_ledger_txn_sequence.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml
new file mode 100644
index 000000000..57be7341c
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml
new file mode 100644
index 000000000..67d708297
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+ INSERT INTO m_code (code_name, is_system_defined)
+ SELECT 'GLEntryType', true
+ WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'GLEntryType');
+
+
+
+
+
+ INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory)
+ SELECT c.id, v.code_value, v.order_position, true, false
+ FROM m_code c
+ CROSS JOIN (VALUES
+ ('General', 1),
+ ('Share Collection', 2),
+ ('DD Issue', 3)
+ ) AS v(code_value, order_position)
+ WHERE c.code_name = 'GLEntryType'
+ AND NOT EXISTS (
+ SELECT 1 FROM m_code_value cv
+ WHERE cv.code_id = c.id AND cv.code_value = v.code_value
+ );
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UPDATE m_cashier_deposit_request d
+ SET entry_type_cv_id = cv.id
+ FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id
+ WHERE c.code_name = 'GLEntryType'
+ AND cv.code_value = CASE d.entry_type
+ WHEN 'SHARE_COLLECTION' THEN 'Share Collection'
+ WHEN 'DD_ISSUE' THEN 'DD Issue'
+ ELSE 'General'
+ END
+ AND d.entry_type_cv_id IS NULL;
+
+ UPDATE m_clerk_disbursement_request d
+ SET entry_type_cv_id = cv.id
+ FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id
+ WHERE c.code_name = 'GLEntryType'
+ AND cv.code_value = CASE d.entry_type
+ WHEN 'SHARE_COLLECTION' THEN 'Share Collection'
+ WHEN 'DD_ISSUE' THEN 'DD Issue'
+ ELSE 'General'
+ END
+ AND d.entry_type_cv_id IS NULL;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml
new file mode 100644
index 000000000..09500687a
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+