From ba199d78e7aee11f32f4f238679ee979459a2718 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Wed, 1 Jul 2026 16:50:08 +0530 Subject: [PATCH] feat(election): date validations, overdue-only NDC/loan checks, audit log gaps fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/ElectionConfigApiResource.java | 17 +++++- .../api/ElectionNominationApiResource.java | 18 ++++++- .../api/ElectionVotingApiResource.java | 5 +- .../election/domain/ElectionAuditAction.java | 2 +- .../ElectionDateViolationException.java | 10 ++++ .../MemberHasOutstandingDuesException.java | 11 ++++ .../ElectionCeoPreCheckServiceImpl.java | 3 +- .../ElectionNoDueCertificateServiceImpl.java | 3 +- .../ElectionWritePlatformServiceImpl.java | 54 +++++++++++++++++++ 9 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDateViolationException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/MemberHasOutstandingDuesException.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java index 6239546c9..04cc25edd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java index c80986a19..f969b601d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java @@ -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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java index e2494d9de..c21a700e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java @@ -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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java index 3fcfc6b15..003af340a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDateViolationException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDateViolationException.java new file mode 100644 index 000000000..835acd650 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDateViolationException.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/MemberHasOutstandingDuesException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/MemberHasOutstandingDuesException.java new file mode 100644 index 000000000..20e8d2ab4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/MemberHasOutstandingDuesException.java @@ -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."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java index 213c566e0..86fdac62f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java @@ -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(); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java index e80f331b6..e1fa01603 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java index b8ab35499..231bd03f7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java @@ -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 posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId); for (ElectionPost post : posts) { List 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 posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId); for (ElectionPost post : posts) { List 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); + } + } }