From a098f8ba7542f0eacd3943133f9c5401de126d3c Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 23 Jul 2026 17:24:17 +0530 Subject: [PATCH] fix(chequebook): prevent cheque-leaf serial collisions across series at issuance time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real root cause behind the leaf-serial collision bug: stock-lot overlap checking was scoped to "within the same series only" (ChequeStockLotRepository.countOverlappingInSeries), on the incorrect assumption that two different cheque designs never share a numeric range. A real bank allocates leaf serials from one bank-wide numbering authority, so this is exactly what let two series end up with overlapping ranges that, once both were issued to the same account, produced an unresolvable serial collision (NpciInwardPushService's drawer-account-number disambiguation can't help when every colliding leaf belongs to the same account). Widened the check to countOverlappingAnySeries — both the maker-submit and checker-approval paths in ChequeBookWritePlatformServiceImpl now reject a stock receipt whose range overlaps ANY existing lot, regardless of series. Co-Authored-By: Claude Sonnet 5 --- .../domain/ChequeStockLotRepository.java | 16 +++++++++------- .../ChequeBookReadPlatformServiceImpl.java | 6 ++++-- .../ChequeBookWritePlatformServiceImpl.java | 13 ++++++++----- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java index 0c6676ed6..2f57d49e5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java @@ -28,12 +28,14 @@ public interface ChequeStockLotRepository extends JpaRepository findBySeriesIdAndOfficeIdAndStatusOrderByIdAsc(Long seriesId, Long officeId, String status); /** - * Anti-duplication control: a newly received range must not overlap any existing lot's range within the SAME - * series. Each series is its own stationery batch from the press (different leaf count/paper stock) and is - * numbered independently — two different series legitimately reusing the same numeric range is normal in - * real-world cheque stock, matching how the press assigns a fresh serial block per print order. + * Anti-duplication control: a newly received range must not overlap any existing lot's range, REGARDLESS of + * series. A real bank's leaf-serial numbers are allocated from one bank-wide numbering authority — two + * different cheque designs (series) never legitimately reuse the same numeric range, because whichever + * account eventually receives books from both would end up holding two leaves with the identical serial + * (a collision NpciInwardPushService's drawer-account-number disambiguation can't resolve when both leaves + * belong to the same account). This used to be scoped to "within the same series only", which is exactly + * what let that collision happen in the first place. */ - @Query("SELECT COUNT(l) FROM ChequeStockLot l WHERE l.series.id = :seriesId AND l.rangeStart <= :rangeEnd AND l.rangeEnd >= :rangeStart") - long countOverlappingInSeries(@Param("seriesId") long seriesId, @Param("rangeStart") long rangeStart, - @Param("rangeEnd") long rangeEnd); + @Query("SELECT COUNT(l) FROM ChequeStockLot l WHERE l.rangeStart <= :rangeEnd AND l.rangeEnd >= :rangeStart") + long countOverlappingAnySeries(@Param("rangeStart") long rangeStart, @Param("rangeEnd") long rangeEnd); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java index 1827f9a00..9bebce03b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java @@ -223,8 +223,10 @@ public class ChequeBookReadPlatformServiceImpl implements ChequeBookReadPlatform @Override public List retrieveLeafBySerialNo(final Long leafSerialNo) { - // a bare serial number is only unique WITHIN a series (see ChequeStockLotRepository.countOverlappingInSeries) — - // two different series can legitimately share a number, so this can return more than one match + // new stock lots are now checked for overlap across every series (see + // ChequeStockLotRepository.countOverlappingAnySeries), so a bare serial number stays globally unique going + // forward. Older lots received before that check existed can still collide, so this stays defensive and + // may return more than one match for pre-existing data. final String sql = "select " + this.leafMapper.schema() + " where f.leaf_serial_no = ? order by s.series_code asc"; final List leaves = this.jdbcTemplate.query(sql, this.leafMapper, leafSerialNo); if (leaves.isEmpty()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java index e7e9e0b6a..2e0deae3e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java @@ -185,13 +185,14 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo @Override public CommandProcessingResult receiveStock(final JsonCommand command) { this.validator.validateForStockLotReceive(command.json()); - final Long seriesId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue()); final Long rangeStart = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.RANGE_START.getValue()); final Long rangeEnd = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.RANGE_END.getValue()); // fail fast for the maker; re-checked authoritatively at approval time in case stock changed meanwhile - if (this.chequeStockLotRepository.countOverlappingInSeries(seriesId, rangeStart, rangeEnd) > 0) { + if (this.chequeStockLotRepository.countOverlappingAnySeries(rangeStart, rangeEnd) > 0) { throw new GeneralPlatformDomainRuleException("error.msg.chequestocklot.range.overlap", - "The given leaf-serial range overlaps with an existing stock lot for this series.", rangeStart); + "The given leaf-serial range overlaps with an existing stock lot (checked across every series, " + + "not just this one) — pick a different range.", + rangeStart); } final Long pendingId = submitPendingRequest(ChequeBookPendingRequestType.RECEIVE_STOCK, command); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(pendingId).build(); @@ -208,9 +209,11 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo final Long rangeEnd = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.RANGE_END.getValue()); final String batchRef = command.stringValueOfParameterNamedAllowingNull(ChequeBookApiJsonInputParams.BATCH_REF.getValue()); - if (this.chequeStockLotRepository.countOverlappingInSeries(seriesId, rangeStart, rangeEnd) > 0) { + if (this.chequeStockLotRepository.countOverlappingAnySeries(rangeStart, rangeEnd) > 0) { throw new GeneralPlatformDomainRuleException("error.msg.chequestocklot.range.overlap", - "The given leaf-serial range overlaps with an existing stock lot for this series.", rangeStart); + "The given leaf-serial range overlaps with an existing stock lot (checked across every series, " + + "not just this one) — pick a different range.", + rangeStart); } final ChequeSeries series = this.chequeSeriesRepositoryWrapper.findOneWithNotFoundDetection(seriesId);