fix(share-account+clerk): remove cashier collection validation, add NPE guards

ShareAccountWritePlatformServiceJpaRepositoryImpl:
- Remove m_share_cashier_collection validation from activateShareAccount() —
  cashier now enters GL directly via transaction entry workstation
- Mark all share account charges as paid on activation instead of posting
  duplicate GL (cashier already posted DR Cash -> CR Charges GL directly)
- Remove unused imports: java.sql.Date, EmptyResultDataAccessException, Collectors
- Update createShareAccount() comment to reflect new cashier GL flow

ClerkDisbursementRequestApiResource:
- Add null guard for clerk_id before cast in approve() — returns 500 with
  clear message if DB record is missing the field
- Add null/blank guard for credit_entries in approve() — returns 400 with
  clear message instead of NPE on gson.fromJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-17 18:46:17 +05:30
co-authored by Claude Sonnet 4.6
parent 7354da8c1e
commit c1e23080fc
2 changed files with 20 additions and 56 deletions
@@ -366,13 +366,20 @@ public class ClerkDisbursementRequestApiResource {
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final Object clerkIdObj = record.get("clerk_id");
if (clerkIdObj == null) {
throw apiError(500, "Disbursement request " + id + " has no clerk ID on record.");
}
final Long makerId = ((Number) clerkIdObj).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403,
"Maker-checker: you cannot approve a disbursement request you submitted yourself.");
}
final String creditEntriesJson = (String) record.get("credit_entries");
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
throw apiError(400, "Disbursement request " + id + " has no credit entries.");
}
final JsonArray creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
final Object ptId = record.get("payment_type_id");
final String description = (String) record.get("description");
@@ -20,7 +20,6 @@ package org.apache.fineract.portfolio.shareaccounts.service;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
@@ -28,7 +27,6 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
@@ -57,7 +55,6 @@ import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDat
import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
@@ -89,11 +86,10 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
this.shareAccountRepository.saveAndFlush(account);
generateAccountNumber(account);
// GL entries are intentionally NOT posted at submission time.
// Cash is physically collected by the cashier AFTER submission via the
// /cashier/sharecollection endpoint, which posts:
// Dr Cash at Vault → Cr Share Sundry (share amount)
// Cr Income from Fees (charge amount)
// Posting GL here would cause a duplicate entry when cashier collects.
// Cashier collects cash and enters GL directly in the transaction entry
// workstation: DR Cash → CR Share Sundry GL (share amount) and
// DR Cash → CR Charges GL (charge amount). The approval step posts the
// share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account));
@@ -355,59 +351,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) {
try {
// ── Validate: verified cashier collection record must exist ────────
// Checks m_share_cashier_collection for a record where:
// 1. share_account_id = exactly this account (no cross-account match)
// 2. total_amount > 0 (real cash was received, not a zero entry)
// 3. collection_date within MAX_COLLECTION_AGE_DAYS (not a stale record)
final int maxAgeDays = 30;
final Date oldestValidDate = Date.valueOf(LocalDate.now().minusDays(maxAgeDays));
final Map<String, Object> collectionRecord;
try {
collectionRecord = jdbcTemplate.queryForMap(
"SELECT share_account_id, collected_by_name, collection_date, "
+ " total_amount, gl_transaction_id "
+ "FROM m_share_cashier_collection "
+ "WHERE share_account_id = ? "
+ " AND total_amount > 0 "
+ " AND collection_date >= ?",
accountId, oldestValidDate);
} catch (EmptyResultDataAccessException e) {
throw new org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException(
"error.msg.share.activation.cashier.collection.required",
"Share account " + accountId + " cannot be activated: "
+ "no cashier cash-collection record found within the last "
+ maxAgeDays + " days. "
+ "Please complete the Share Collection step in the Cashier menu first.");
}
// Query BEFORE activation which charges were already paid via cashier collection.
// Cashier collection sets is_paid_derived = true; activation would otherwise
// post a duplicate Dr Cash → Cr Income entry for those same charges.
final Set<Long> cashierPaidChargeIds = new HashSet<>(jdbcTemplate.queryForList(
"SELECT id FROM m_share_account_charge "
+ "WHERE account_id = ? AND is_paid_derived = true",
Long.class, accountId));
ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
Map<String, Object> changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account);
if (!changes.isEmpty()) {
this.shareAccountRepository.save(account);
}
// Filter out charge transactions whose charges were already collected
// by the cashier — prevents duplicate GL entries on activation.
Set<ShareAccountTransaction> chargesForGL = account.getChargeTransactions().stream()
.filter(txn -> txn.getChargesPaidBy() == null
|| txn.getChargesPaidBy().stream()
.noneMatch(cpb -> cashierPaidChargeIds.contains(cpb.getShareChargeId())))
.collect(Collectors.toSet());
if (!chargesForGL.isEmpty()) {
this.journalEntryWritePlatformService
.createJournalEntriesForShares(populateJournalEntries(account, chargesForGL));
}
// Cashier collected cash and entered GL directly via the transaction entry
// workstation (DR Cash → CR Share Sundry GL for share amount, and DR Cash → CR
// Charges GL for charges). Mark charges as paid to reflect cashier's collection
// without posting duplicate GL entries — the approval step already posted the
// share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
jdbcTemplate.update(
"UPDATE m_share_account_charge SET is_paid_derived = true WHERE account_id = ?",
accountId);
return new CommandProcessingResultBuilder() //
.withCommandId(jsonCommand.commandId()) //