fix(chequebook): prevent cheque-leaf serial collisions across series at issuance time
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 17:24:17 +05:30
co-authored by Claude Sonnet 5
parent ab5666c0f4
commit a098f8ba75
3 changed files with 21 additions and 14 deletions
@@ -28,12 +28,14 @@ public interface ChequeStockLotRepository extends JpaRepository<ChequeStockLot,
List<ChequeStockLot> 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);
}
@@ -223,8 +223,10 @@ public class ChequeBookReadPlatformServiceImpl implements ChequeBookReadPlatform
@Override
public List<ChequeLeafData> 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<ChequeLeafData> leaves = this.jdbcTemplate.query(sql, this.leafMapper, leafSerialNo);
if (leaves.isEmpty()) {
@@ -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);