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 701e9e4e8..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);
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..c0c72ff3b 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 : "");
@@ -472,14 +474,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,
@@ -809,6 +823,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