feat(election): date validations, overdue-only NDC/loan checks, audit log gaps fixed

- Added ElectionDateViolationException for proper 4xx on date violations
- createElection: validates date order (nominationStart <= nominationEnd <= electionDate <= resultDate)
- withdrawNomination/resubmitNomination: blocked after nominationEndDate
- closeNominations: requires today >= nominationEndDate
- openVoting/closeVoting: requires today >= electionDate
- declareResults: requires today >= resultDate (or electionDate if no resultDate)
- NDC hasNoDues: checks overdue EMIs only (total_overdue_derived), not outstanding balance
- Director/guarantor/family loan checks: overdue-based only — pass if no missed EMIs
- MemberHasOutstandingDuesException: proper AbstractPlatformDomainRuleException (was IllegalStateException → 500)
- Audit gaps fixed: RESUBMIT_NOMINATION_FAILED added to enum, try/catch added for resubmit,
  createElection, updateElection; saveManualVoteCounts catches all RuntimeException

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-01 16:50:08 +05:30
co-authored by Claude Sonnet 4.6
parent 99cc972804
commit ba199d78e7
9 changed files with 113 additions and 10 deletions
@@ -60,7 +60,14 @@ public class ElectionConfigApiResource {
@Operation(summary = "Create a new election")
public Response createElection(ElectionConfigRequest request,
@Context HttpServletRequest httpRequest) {
Long id = writeService.createElection(request);
Long id;
try {
id = writeService.createElection(request);
} catch (RuntimeException e) {
electionAuditService.logInNewTransaction(ElectionAuditAction.CREATE_ELECTION, "ELECTION", null, null,
"Election creation failed: " + e.getMessage(), null, clientIp(httpRequest));
throw e;
}
electionAuditService.log(ElectionAuditAction.CREATE_ELECTION, "ELECTION", id, id,
"Election created: " + request.getTitle(),
request.toString(),
@@ -74,7 +81,13 @@ public class ElectionConfigApiResource {
public Response updateElection(@PathParam("electionId") Long electionId,
ElectionConfigRequest request,
@Context HttpServletRequest httpRequest) {
writeService.updateElection(electionId, request);
try {
writeService.updateElection(electionId, request);
} catch (RuntimeException e) {
electionAuditService.logInNewTransaction(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId,
"Election update failed: " + e.getMessage(), null, clientIp(httpRequest));
throw e;
}
String updateDetails = null;
try { updateDetails = objectMapper.writeValueAsString(request); } catch (Exception ignored) {}
electionAuditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId,
@@ -108,7 +108,13 @@ public class ElectionNominationApiResource {
public Response resubmitNomination(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId,
@Context HttpServletRequest httpRequest) {
writeService.resubmitNomination(nominationId);
try {
writeService.resubmitNomination(nominationId);
} catch (RuntimeException e) {
electionAuditService.logInNewTransaction(ElectionAuditAction.RESUBMIT_NOMINATION_FAILED,
"NOMINATION", nominationId, electionId, "Resubmit failed: " + e.getMessage(), null, clientIp(httpRequest));
throw e;
}
electionAuditService.log(ElectionAuditAction.RESUBMIT_NOMINATION, "NOMINATION", nominationId, electionId,
"Nomination resubmitted", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", nominationId, "status", "UNDER_REVIEW")).build();
@@ -213,7 +219,15 @@ public class ElectionNominationApiResource {
public Response generateNoDueCertificate(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId,
@Context HttpServletRequest httpRequest) {
String certNumber = writeService.generateNoDueCertificate(nominationId);
String certNumber;
try {
certNumber = writeService.generateNoDueCertificate(nominationId);
} catch (org.apache.fineract.portfolio.election.exception.MemberHasOutstandingDuesException e) {
electionAuditService.logInNewTransaction(ElectionAuditAction.GENERATE_NO_DUE_CERTIFICATE,
"NOMINATION", nominationId, electionId,
"NDC generation failed — member has outstanding dues", null, clientIp(httpRequest));
throw e;
}
electionAuditService.log(ElectionAuditAction.GENERATE_NO_DUE_CERTIFICATE, "NOMINATION", nominationId, electionId,
"No due certificate generated: " + certNumber, null, clientIp(httpRequest));
return Response.ok(Map.of("certificateNumber", certNumber)).build();
@@ -25,7 +25,6 @@ import org.apache.fineract.portfolio.election.data.ElectionResultData;
import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry;
import org.apache.fineract.portfolio.election.data.ManualVoteCountsRequest;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException;
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
@@ -58,10 +57,10 @@ public class ElectionVotingApiResource {
String voteDetails;
try {
voteDetails = writeService.saveManualVoteCounts(electionId, counts, reason);
} catch (VoteCountExceedsShareAccountsException e) {
} catch (RuntimeException e) {
electionAuditService.logInNewTransaction(
ElectionAuditAction.MANUAL_VOTE_ENTRY_FAILED, "VOTE", electionId, electionId,
"Vote count validation failed: " + e.getDefaultUserMessage(),
"Vote count entry failed: " + e.getMessage(),
null, clientIp(httpRequest));
throw e;
}
@@ -13,7 +13,7 @@ public enum ElectionAuditAction {
ELIGIBILITY_CHECK, CEO_PRECHECK, APPROVAL_ACTION,
GENERATE_NO_DUE_CERTIFICATE,
// Nomination — Failed
SUBMIT_NOMINATION_FAILED, WITHDRAW_NOMINATION_FAILED,
SUBMIT_NOMINATION_FAILED, WITHDRAW_NOMINATION_FAILED, RESUBMIT_NOMINATION_FAILED,
ELIGIBILITY_CHECK_FAILED, CEO_PRECHECK_FAILED, APPROVAL_ACTION_FAILED,
// Candidate
APPROVE_CANDIDATE, REMOVE_CANDIDATE, UPLOAD_SYMBOL, UPDATE_CANDIDATE,
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.election.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ElectionDateViolationException extends AbstractPlatformDomainRuleException {
public ElectionDateViolationException(String message) {
super("error.msg.election.date.violation", message);
}
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.election.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class MemberHasOutstandingDuesException extends AbstractPlatformDomainRuleException {
public MemberHasOutstandingDuesException() {
super("error.msg.election.member.outstanding.dues",
"Member has outstanding dues. No Due Certificate cannot be issued.");
}
}
@@ -6,6 +6,7 @@ import org.apache.fineract.portfolio.election.domain.CheckResult;
import org.apache.fineract.portfolio.election.domain.ElectionCeoPrecheck;
import org.apache.fineract.portfolio.election.domain.ElectionCeoPrecheckRepository;
import org.apache.fineract.portfolio.election.domain.ElectionNomination;
import org.apache.fineract.portfolio.election.exception.MemberHasOutstandingDuesException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -366,7 +367,7 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic
precheck.completeDirectorLoanCheck(CheckResult.FAIL, BigDecimal.ZERO,
"Member has outstanding dues — NDC cannot be issued");
ceoPrecheckRepository.save(precheck);
throw new IllegalStateException("Member has outstanding dues. No Due Certificate cannot be issued.");
throw new MemberHasOutstandingDuesException();
}
}
}
@@ -28,9 +28,10 @@ public class ElectionNoDueCertificateServiceImpl implements ElectionNoDueCertifi
String loanDueQuery = """
SELECT COUNT(la.id)
FROM m_loan la
LEFT JOIN m_loan_arrears_aging a ON a.loan_id = la.id
WHERE la.client_id = ?
AND la.loan_status_id = 300
AND la.total_outstanding_derived > 0
AND COALESCE(a.total_overdue_derived, 0) > 0
""";
Integer loanDueCount = jdbcTemplate.queryForObject(loanDueQuery, Integer.class, memberId);
return loanDueCount == null || loanDueCount == 0;
@@ -29,6 +29,7 @@ import org.apache.fineract.portfolio.election.domain.ElectionVote;
import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository;
import org.apache.fineract.portfolio.election.domain.NominationStatus;
import org.apache.fineract.portfolio.election.exception.ElectionAlreadyOngoingException;
import org.apache.fineract.portfolio.election.exception.ElectionDateViolationException;
import org.apache.fineract.portfolio.election.exception.ElectionNotFoundException;
import org.apache.fineract.portfolio.election.exception.ElectionNominationNotFoundException;
import org.apache.fineract.portfolio.election.exception.ElectionNotActiveException;
@@ -42,6 +43,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -90,6 +92,9 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
throw new ElectionAlreadyOngoingException();
}
validateDateOrder(request.getNominationStartDate(), request.getNominationEndDate(),
request.getElectionDate(), request.getResultDate());
ElectionConfig election = ElectionConfig.create(
request.getTitle(),
request.getDescription(),
@@ -102,6 +107,9 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionConfig saved = electionConfigRepository.saveAndFlush(election);
if (request.getPosts() != null) {
if (request.getPosts().size() > 1) {
throw new IllegalArgumentException("Only one election post is allowed per election.");
}
for (ElectionPostRequest postReq : request.getPosts()) {
ElectionPost post = ElectionPost.create(saved, postReq.getPostName(), postReq.getTotalSeats());
electionPostRepository.saveAndFlush(post);
@@ -142,6 +150,10 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION");
ElectionConfig election = electionConfigRepository.findById(electionId)
.orElseThrow(() -> new ElectionNotFoundException(electionId));
if (LocalDate.now().isBefore(election.getNominationEndDate())) {
throw new ElectionDateViolationException(
"Cannot close nominations before the nomination end date " + election.getNominationEndDate());
}
election.closeNominations();
electionConfigRepository.save(election);
}
@@ -152,6 +164,10 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION");
ElectionConfig election = electionConfigRepository.findById(electionId)
.orElseThrow(() -> new ElectionNotFoundException(electionId));
if (LocalDate.now().isBefore(election.getElectionDate())) {
throw new ElectionDateViolationException(
"Cannot open voting before election date " + election.getElectionDate());
}
election.openVoting();
electionConfigRepository.save(election);
}
@@ -163,6 +179,11 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionConfig election = electionConfigRepository.findById(electionId)
.orElseThrow(() -> new ElectionNotFoundException(electionId));
if (LocalDate.now().isBefore(election.getElectionDate())) {
throw new ElectionDateViolationException(
"Cannot close voting before election date " + election.getElectionDate());
}
List<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
for (ElectionPost post : posts) {
List<ElectionCandidate> candidates = candidateRepository
@@ -243,6 +264,11 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
if (nomination.getStatus() == NominationStatus.APPROVED) {
throw new InvalidNominationStateException("withdraw", nomination.getStatus().name());
}
ElectionConfig election = nomination.getElectionConfig();
if (LocalDate.now().isAfter(election.getNominationEndDate())) {
throw new ElectionDateViolationException(
"Nomination withdrawal period has ended. Nominations closed on " + election.getNominationEndDate());
}
nomination.withdraw();
nominationRepository.save(nomination);
}
@@ -255,6 +281,11 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
if (nomination.getStatus() != NominationStatus.REJECTED) {
throw new InvalidNominationStateException("resubmit", nomination.getStatus().name());
}
ElectionConfig election = nomination.getElectionConfig();
if (LocalDate.now().isAfter(election.getNominationEndDate())) {
throw new ElectionDateViolationException(
"Nomination resubmission period has ended. Nominations closed on " + election.getNominationEndDate());
}
ceoPrecheckRepository.findByNominationId(nominationId)
.ifPresent(ceoPrecheckRepository::delete);
nomination.resubmit();
@@ -521,6 +552,13 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionConfig election = electionConfigRepository.findById(electionId)
.orElseThrow(() -> new ElectionNotFoundException(electionId));
LocalDate today = LocalDate.now();
LocalDate effectiveDate = election.getResultDate() != null ? election.getResultDate() : election.getElectionDate();
if (today.isBefore(effectiveDate)) {
throw new ElectionDateViolationException(
"Cannot declare results before " + effectiveDate);
}
List<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
for (ElectionPost post : posts) {
List<ElectionCandidate> candidates = candidateRepository
@@ -580,4 +618,20 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
private String generateApplicationNumber(Long nominationId, Long electionId) {
return "ENOMN-" + electionId + "-" + String.format("%06d", nominationId);
}
private void validateDateOrder(LocalDate nominationStart, LocalDate nominationEnd,
LocalDate electionDate, LocalDate resultDate) {
if (nominationStart != null && nominationEnd != null && nominationStart.isAfter(nominationEnd)) {
throw new ElectionDateViolationException(
"Nomination start date " + nominationStart + " cannot be after nomination end date " + nominationEnd);
}
if (nominationEnd != null && electionDate != null && nominationEnd.isAfter(electionDate)) {
throw new ElectionDateViolationException(
"Nomination end date " + nominationEnd + " cannot be after election date " + electionDate);
}
if (electionDate != null && resultDate != null && electionDate.isAfter(resultDate)) {
throw new ElectionDateViolationException(
"Election date " + electionDate + " cannot be after result date " + resultDate);
}
}
}