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:
co-authored by
Claude Sonnet 4.6
parent
7354da8c1e
commit
c1e23080fc
+8
-1
@@ -366,13 +366,20 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + 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())) {
|
if (makerId.equals(currentUser.getId())) {
|
||||||
throw apiError(403,
|
throw apiError(403,
|
||||||
"Maker-checker: you cannot approve a disbursement request you submitted yourself.");
|
"Maker-checker: you cannot approve a disbursement request you submitted yourself.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final String creditEntriesJson = (String) record.get("credit_entries");
|
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 JsonArray creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
|
||||||
final Object ptId = record.get("payment_type_id");
|
final Object ptId = record.get("payment_type_id");
|
||||||
final String description = (String) record.get("description");
|
final String description = (String) record.get("description");
|
||||||
|
|||||||
+12
-55
@@ -20,7 +20,6 @@ package org.apache.fineract.portfolio.shareaccounts.service;
|
|||||||
|
|
||||||
import jakarta.persistence.PersistenceException;
|
import jakarta.persistence.PersistenceException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.sql.Date;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -28,7 +27,6 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
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.ShareProduct;
|
||||||
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
|
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
|
|
||||||
@@ -89,11 +86,10 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
this.shareAccountRepository.saveAndFlush(account);
|
this.shareAccountRepository.saveAndFlush(account);
|
||||||
generateAccountNumber(account);
|
generateAccountNumber(account);
|
||||||
// GL entries are intentionally NOT posted at submission time.
|
// GL entries are intentionally NOT posted at submission time.
|
||||||
// Cash is physically collected by the cashier AFTER submission via the
|
// Cashier collects cash and enters GL directly in the transaction entry
|
||||||
// /cashier/sharecollection endpoint, which posts:
|
// workstation: DR Cash → CR Share Sundry GL (share amount) and
|
||||||
// Dr Cash at Vault → Cr Share Sundry (share amount)
|
// DR Cash → CR Charges GL (charge amount). The approval step posts the
|
||||||
// Cr Income from Fees (charge amount)
|
// share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
|
||||||
// Posting GL here would cause a duplicate entry when cashier collects.
|
|
||||||
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account));
|
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account));
|
||||||
|
|
||||||
@@ -355,59 +351,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) {
|
public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) {
|
||||||
|
|
||||||
try {
|
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);
|
ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
|
||||||
Map<String, Object> changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account);
|
Map<String, Object> changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account);
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
this.shareAccountRepository.save(account);
|
this.shareAccountRepository.save(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out charge transactions whose charges were already collected
|
// Cashier collected cash and entered GL directly via the transaction entry
|
||||||
// by the cashier — prevents duplicate GL entries on activation.
|
// workstation (DR Cash → CR Share Sundry GL for share amount, and DR Cash → CR
|
||||||
Set<ShareAccountTransaction> chargesForGL = account.getChargeTransactions().stream()
|
// Charges GL for charges). Mark charges as paid to reflect cashier's collection
|
||||||
.filter(txn -> txn.getChargesPaidBy() == null
|
// without posting duplicate GL entries — the approval step already posted the
|
||||||
|| txn.getChargesPaidBy().stream()
|
// share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
|
||||||
.noneMatch(cpb -> cashierPaidChargeIds.contains(cpb.getShareChargeId())))
|
jdbcTemplate.update(
|
||||||
.collect(Collectors.toSet());
|
"UPDATE m_share_account_charge SET is_paid_derived = true WHERE account_id = ?",
|
||||||
|
accountId);
|
||||||
if (!chargesForGL.isEmpty()) {
|
|
||||||
this.journalEntryWritePlatformService
|
|
||||||
.createJournalEntriesForShares(populateJournalEntries(account, chargesForGL));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(jsonCommand.commandId()) //
|
.withCommandId(jsonCommand.commandId()) //
|
||||||
|
|||||||
Reference in New Issue
Block a user