feat(ctsclearing): signature-verification gate and full NPCI field capture for inward cheques
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 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 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 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 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 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 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

Adds a manual signature-verification step to the inward CTS decision flow:
a new VERIFY_SIGNATURE command records the clerk's confirmation, and
InwardClearingItem.pass() now refuses to pass a cheque until signatureOk
is true — enforced server-side, not just a disabled UI button.

Fixes a real bug where ChequeLeaf.leafSerialNo collides across different
cheque books/series (by design — serials are only unique per stock lot),
which could resolve an inward item to the wrong account. NpciInwardPushService
now disambiguates using the drawer account number NPCI already sends
alongside the cheque number.

Also closes a completeness gap found by auditing the outward-CBS -> NPCI ->
inward-CBS pipeline: five fields NPCI sends on every instrument (drawer
account number, deposit account number, instrument type/description, drawee
bank name, per-check IQA breakdown) were read and discarded instead of
persisted — now saved via new columns and exposed on the read API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-22 12:30:07 +05:30
co-authored by Claude Sonnet 5
parent b433edfdfe
commit ab5666c0f4
14 changed files with 319 additions and 24 deletions
@@ -297,6 +297,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder verifyInwardClearingItemSignature(final Long itemId) {
this.actionName = "VERIFY_SIGNATURE";
this.entityName = "INWARDCLEARINGITEM";
this.entityId = itemId;
this.href = "/ctsclearing/inward-items/" + itemId;
return this;
}
public CommandWrapperBuilder createPositivePayRegistration() {
this.actionName = "CREATE";
this.entityName = "POSITIVEPAYREGISTRATION";
@@ -26,6 +26,13 @@ public interface ChequeLeafRepository extends JpaRepository<ChequeLeaf, Long> {
Optional<ChequeLeaf> findByLeafSerialNo(Long leafSerialNo);
/**
* leafSerialNo is only unique per stock lot (see 0387_chequeleaf_serial_unique_per_lot.xml) — two
* different lots/books can legitimately share the same serial. Callers that need to disambiguate should
* use this instead of {@link #findByLeafSerialNo}, which throws when more than one row matches.
*/
List<ChequeLeaf> findAllByLeafSerialNo(Long leafSerialNo);
List<ChequeLeaf> findByChequeBookIdOrderByLeafSeqNoAsc(Long chequeBookId);
List<ChequeLeaf> findByChequeBookIdAndLeafSeqNoBetweenOrderByLeafSeqNoAsc(Long chequeBookId, Integer fromSeqNo,
@@ -44,6 +44,10 @@ import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingBatchRepos
import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingBatchRepositoryWrapper;
import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingItem;
import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingItemRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -69,12 +73,15 @@ public class NpciInwardPushService {
private final InwardClearingBatchRepositoryWrapper batchRepositoryWrapper;
private final InwardClearingItemRepository itemRepository;
private final ChequeLeafRepository chequeLeafRepository;
private final SavingsAccountRepository savingsAccountRepository;
private final CurrentAccountRepository currentAccountRepository;
private final ContentStoreService contentStoreService;
public NpciInwardPushService(final NpciCryptoUtil cryptoUtil, final NpciClearingClient npciClearingClient,
final NpciInwardPushLogRepository pushLogRepository, final InwardClearingBatchRepository batchRepository,
final InwardClearingBatchRepositoryWrapper batchRepositoryWrapper, final InwardClearingItemRepository itemRepository,
final ChequeLeafRepository chequeLeafRepository, final ContentStoreService contentStoreService) {
final ChequeLeafRepository chequeLeafRepository, final SavingsAccountRepository savingsAccountRepository,
final CurrentAccountRepository currentAccountRepository, final ContentStoreService contentStoreService) {
this.cryptoUtil = cryptoUtil;
this.npciClearingClient = npciClearingClient;
this.pushLogRepository = pushLogRepository;
@@ -82,6 +89,8 @@ public class NpciInwardPushService {
this.batchRepositoryWrapper = batchRepositoryWrapper;
this.itemRepository = itemRepository;
this.chequeLeafRepository = chequeLeafRepository;
this.savingsAccountRepository = savingsAccountRepository;
this.currentAccountRepository = currentAccountRepository;
this.contentStoreService = contentStoreService;
}
@@ -173,7 +182,10 @@ public class NpciInwardPushService {
final LocalDate instrumentDate = parseDate(getString(instrument, "instrumentDate"));
final LocalDate presentedDate = DateUtils.getBusinessLocalDate();
final ResolvedAccount resolved = resolveAccount(chequeNumber);
// the drawee-side account number NPCI carries (masked, e.g. "XXXXXXXXXXX0032") — disambiguates when
// the cheque number alone matches more than one leaf across different books/lots.
final String drawerAccountNumber = getString(instrument, "drawerAccountNumber");
final ResolvedAccount resolved = resolveAccount(chequeNumber, drawerAccountNumber);
final InwardClearingItem item = InwardClearingItem.receive(batch, resolved.chequeLeafId, resolved.accountType,
resolved.accountId, chequeNumber, micrCode, ifsc, payeeName, amount, amountInWords, instrumentDate, presentedDate);
@@ -194,33 +206,98 @@ public class NpciInwardPushService {
: null;
item.recordSourceDetails(getString(instrument, "ctsReferenceNumber"), instrumentStopPaymentFlag,
parseDate(getString(instrument, "depositDate")));
// fields that don't drive any validation rule, but are worth keeping for audit trail rather than
// silently discarding them once read (or never reading them at all)
final JsonObject iqaChecks = instrument.has("iqaChecks") ? instrument.getAsJsonObject("iqaChecks") : null;
item.recordSourceAccountDetails(drawerAccountNumber, getString(instrument, "depositAccountNumber"),
getString(instrument, "instrumentType"), getString(instrument, "instrumentTypeDescription"),
getString(instrument, "draweeBankName"), iqaChecks == null ? null : iqaChecks.toString());
return item;
}
/** Resolves which of THIS bank's own accounts a cheque number belongs to, via its cheque leaf, if one matches. */
private ResolvedAccount resolveAccount(final String chequeNumber) {
/**
* Resolves which of THIS bank's own accounts a cheque number belongs to, via its cheque leaf. leafSerialNo
* is only unique per stock lot (0387_chequeleaf_serial_unique_per_lot.xml) — two different books can
* legitimately share the same serial — so when more than one leaf matches, this narrows down using the
* masked drawee account number NPCI carries (e.g. "XXXXXXXXXXX0032"), matching its visible tail digits
* against each candidate leaf's own account number. Left unresolved (not a guess) if that still doesn't
* narrow it to exactly one; Validate will flag "No drawn-on account linked" for a human to reconcile,
* same as a real bank would for a genuinely unmatched instrument.
*/
private ResolvedAccount resolveAccount(final String chequeNumber, final String drawerAccountNumberMasked) {
final ResolvedAccount resolved = new ResolvedAccount();
if (chequeNumber == null) {
return resolved;
}
final Long serial;
try {
final Long serial = Long.valueOf(chequeNumber.trim());
final ChequeLeaf leaf = this.chequeLeafRepository.findByLeafSerialNo(serial).orElse(null);
if (leaf != null) {
resolved.chequeLeafId = leaf.getId();
final ChequeBook book = leaf.getChequeBook();
if (book != null) {
resolved.accountType = book.getAccountType();
resolved.accountId = book.getAccountId();
serial = Long.valueOf(chequeNumber.trim());
} catch (final NumberFormatException e) {
return resolved;
}
final List<ChequeLeaf> candidates = this.chequeLeafRepository.findAllByLeafSerialNo(serial);
ChequeLeaf leaf = null;
if (candidates.size() == 1) {
leaf = candidates.get(0);
} else if (candidates.size() > 1) {
final String tail = maskedTail(drawerAccountNumberMasked);
if (tail != null) {
final List<ChequeLeaf> matching = candidates.stream().filter(c -> accountNumberEndsWith(c, tail)).toList();
if (matching.size() == 1) {
leaf = matching.get(0);
log.info("Cheque number {} matched {} cheque leaves across different books — disambiguated via drawee "
+ "account number tail '{}' to leaf {}", chequeNumber, candidates.size(), tail, leaf.getId());
} else {
log.warn(
"Cheque number {} matched {} cheque leaves across different books and the drawee account tail "
+ "'{}' did not narrow it to exactly one ({} still matched) — left unlinked",
chequeNumber, candidates.size(), tail, matching.size());
}
} else {
log.warn("Cheque number {} matched {} cheque leaves across different books and no drawee account number "
+ "was supplied to disambiguate — left unlinked", chequeNumber, candidates.size());
}
}
if (leaf != null) {
resolved.chequeLeafId = leaf.getId();
final ChequeBook book = leaf.getChequeBook();
if (book != null) {
resolved.accountType = book.getAccountType();
resolved.accountId = book.getAccountId();
}
} catch (final RuntimeException e) {
// non-numeric cheque number, or no matching leaf — left unresolved; Validate will flag "No drawn-on
// account linked" and a human reconciles it, same as a real bank would for an unmatched instrument.
}
return resolved;
}
/** The trailing visible digits of a masked account number (e.g. "XXXXXXXXXXX0032" -> "0032"), or null if nothing is masked/usable. */
private static String maskedTail(final String masked) {
if (masked == null) {
return null;
}
final String tail = masked.replaceFirst("^[Xx]+", "").trim();
return tail.isEmpty() ? null : tail;
}
/** True when this leaf's own account's account number ends with the given tail digits. */
private boolean accountNumberEndsWith(final ChequeLeaf leaf, final String tail) {
final ChequeBook book = leaf.getChequeBook();
if (book == null || book.getAccountId() == null) {
return false;
}
final String accountNumber;
if ("CURRENT".equalsIgnoreCase(book.getAccountType())) {
final CurrentAccount account = this.currentAccountRepository.findById(book.getAccountId()).orElse(null);
accountNumber = account == null ? null : account.getAccountNo();
} else {
final SavingsAccount account = this.savingsAccountRepository.findById(book.getAccountId()).orElse(null);
accountNumber = account == null ? null : account.getAccountNumber();
}
return accountNumber != null && accountNumber.endsWith(tail);
}
/** Decodes and stores an instrument's embedded cheque image bytes, if any were included. Best-effort only. */
private void attachImages(final InwardClearingItem item, final JsonObject instrument) {
if (!instrument.has("images")) {
@@ -69,6 +69,7 @@ public final class CtsClearingApiConstants {
public static final String NOTE = "note";
public static final String RETURN_REASON_CODE = "returnReasonCode";
public static final String REASON = "reason";
public static final String SIGNATURE_OK = "signatureOk";
// outward create params
public static final String DEPOSITING_ACCOUNT_TYPE = "depositingAccountType";
@@ -74,9 +74,9 @@ public class InwardClearingItemApiResource {
@Path("{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a command on an inward clearing item", description = "Supported commands: pass, return.")
@Operation(summary = "Run a command on an inward clearing item", description = "Supported commands: pass, return, verifySignature.")
public CommandProcessingResult handleCommand(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("command") @Parameter(description = "pass | return") final String commandParam,
@QueryParam("command") @Parameter(description = "pass | return | verifySignature") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
final CommandWrapper request;
@@ -84,6 +84,8 @@ public class InwardClearingItemApiResource {
request = builder.passInwardClearingItem(id).build();
} else if ("return".equalsIgnoreCase(commandParam)) {
request = builder.returnInwardClearingItem(id).build();
} else if ("verifySignature".equalsIgnoreCase(commandParam)) {
request = builder.verifyInwardClearingItemSignature(id).build();
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
@@ -73,4 +73,10 @@ public class InwardClearingItemData {
private final String ctsReferenceNumber;
private final Boolean instrumentStopPaymentFlag;
private final LocalDate depositDate;
private final String drawerAccountNumberMasked;
private final String depositAccountNumber;
private final String instrumentType;
private final String instrumentTypeDescription;
private final String draweeBankName;
private final String iqaSubChecks;
}
@@ -109,6 +109,30 @@ public class InwardClearingItem extends AbstractPersistableCustom<Long> {
@Column(name = "deposit_date")
private LocalDate depositDate;
/** The drawee (this bank's own customer) account number, masked (e.g. "XXXXXXXXXXX0032"), as carried on
* the wire — kept for audit trail even once resolution is complete. */
@Column(name = "drawer_account_number_masked", length = 40)
private String drawerAccountNumberMasked;
/** The depositing (collecting-bank) customer's own account identifier, if supplied — descriptive only. */
@Column(name = "deposit_account_number", length = 40)
private String depositAccountNumber;
@Column(name = "instrument_type", length = 10)
private String instrumentType;
@Column(name = "instrument_type_description", length = 100)
private String instrumentTypeDescription;
/** The receiving bank's own name as asserted by the sender, for audit cross-check. */
@Column(name = "drawee_bank_name", length = 150)
private String draweeBankName;
/** Per-check IQA breakdown (skew/noise/legibility/completeness), if supplied — the rolled-up outcome
* lives in {@code iqaStatus}/{@code iqaDetail}; this is just the raw detail for reference. */
@Column(name = "iqa_sub_checks", length = 300)
private String iqaSubChecks;
@Column(name = "front_image_ref", length = 300)
private String frontImageRef;
@@ -230,6 +254,22 @@ public class InwardClearingItem extends AbstractPersistableCustom<Long> {
this.depositDate = depositDate;
}
/**
* Records the remaining descriptive fields an NPCI-pushed instrument carries that don't drive any
* validation rule, but were previously read (or not even read) and then silently discarded instead of
* being kept for audit trail.
*/
public void recordSourceAccountDetails(final String drawerAccountNumberMasked, final String depositAccountNumber,
final String instrumentType, final String instrumentTypeDescription, final String draweeBankName,
final String iqaSubChecks) {
this.drawerAccountNumberMasked = drawerAccountNumberMasked;
this.depositAccountNumber = depositAccountNumber;
this.instrumentType = instrumentType;
this.instrumentTypeDescription = instrumentTypeDescription;
this.draweeBankName = draweeBankName;
this.iqaSubChecks = iqaSubChecks;
}
/**
* Attaches the scanned front/back image references and the image-quality (IQA) outcome received with the
* cheque (spec section 5). {@code iqaStatus} defaults to NA when the caller supplies no image.
@@ -275,6 +315,15 @@ public class InwardClearingItem extends AbstractPersistableCustom<Long> {
this.positivePayCheck = positivePayCheck;
}
/**
* Records the clerk's manual signature-verification decision — visually comparing the cheque against the
* account's mandatory signatories, since no automated signature-image matching exists here. Required
* before {@link #pass} will allow the item through.
*/
public void recordSignatureCheck(final Boolean signatureOk) {
this.signatureOk = signatureOk;
}
public void placeHold(final BigDecimal holdAmount) {
this.holdPlaced = true;
this.holdAmount = holdAmount;
@@ -312,6 +361,10 @@ public class InwardClearingItem extends AbstractPersistableCustom<Long> {
public void pass(final String reasonNote, final Long userId) {
assertDecidable();
if (!Boolean.TRUE.equals(this.signatureOk)) {
throw new GeneralPlatformDomainRuleException("error.msg.inward.clearing.item.signature.not.verified",
"Inward clearing item " + getId() + " cannot be passed until signature verification is confirmed.", getId());
}
this.decision = InwardClearingItemDecision.PASS.name();
this.decisionReason = reasonNote;
this.decisionedBy = userId;
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.ctsclearing.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INWARDCLEARINGITEM", action = "VERIFY_SIGNATURE")
@RequiredArgsConstructor
public class VerifyInwardClearingItemSignatureCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.verifySignature(command.entityId(), command);
}
}
@@ -415,7 +415,10 @@ public class CtsClearingReadPlatformServiceImpl implements CtsClearingReadPlatfo
+ "i.hold_amount as hold_amount, i.settlement_batch_id as settlement_batch_id, "
+ "i.original_serial_number as original_serial_number, i.presenting_branch_code as presenting_branch_code, "
+ "i.return_deadline as return_deadline, i.cts_reference_number as cts_reference_number, "
+ "i.instrument_stop_payment_flag as instrument_stop_payment_flag, i.deposit_date as deposit_date "
+ "i.instrument_stop_payment_flag as instrument_stop_payment_flag, i.deposit_date as deposit_date, "
+ "i.drawer_account_number_masked as drawer_account_number_masked, i.deposit_account_number as deposit_account_number, "
+ "i.instrument_type as instrument_type, i.instrument_type_description as instrument_type_description, "
+ "i.drawee_bank_name as drawee_bank_name, i.iqa_sub_checks as iqa_sub_checks "
+ "from m_inward_clearing_item i";
}
@@ -446,7 +449,12 @@ public class CtsClearingReadPlatformServiceImpl implements CtsClearingReadPlatfo
.returnDeadline(rs.getObject("return_deadline", LocalDate.class))
.ctsReferenceNumber(rs.getString("cts_reference_number"))
.instrumentStopPaymentFlag((Boolean) rs.getObject("instrument_stop_payment_flag"))
.depositDate(rs.getObject("deposit_date", LocalDate.class)).build();
.depositDate(rs.getObject("deposit_date", LocalDate.class))
.drawerAccountNumberMasked(rs.getString("drawer_account_number_masked"))
.depositAccountNumber(rs.getString("deposit_account_number"))
.instrumentType(rs.getString("instrument_type"))
.instrumentTypeDescription(rs.getString("instrument_type_description"))
.draweeBankName(rs.getString("drawee_bank_name")).iqaSubChecks(rs.getString("iqa_sub_checks")).build();
}
}
@@ -31,6 +31,9 @@ public interface CtsClearingWritePlatformService {
CommandProcessingResult returnItem(Long itemId, JsonCommand command);
/** Records the clerk's manual signature-verification decision — required before {@link #passItem} allows a PASS. */
CommandProcessingResult verifySignature(Long itemId, JsonCommand command);
CommandProcessingResult createOutward(JsonCommand command);
CommandProcessingResult sendOutward(Long presentmentId, JsonCommand command);
@@ -23,8 +23,10 @@ import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -269,6 +271,18 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(itemId).build();
}
@Transactional
@Override
public CommandProcessingResult verifySignature(final Long itemId, final JsonCommand command) {
final InwardClearingItem item = this.itemRepositoryWrapper.findOneWithNotFoundDetection(itemId);
assertDecidable(item);
final Boolean signatureOk = this.fromApiJsonHelper.extractBooleanNamed(CtsClearingApiConstants.SIGNATURE_OK, command.parsedJson());
item.recordSignatureCheck(signatureOk);
this.itemRepositoryWrapper.saveAndFlush(item);
log.info("Inward clearing item {} signature verification recorded: {}", itemId, signatureOk);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(itemId).build();
}
// ============================= Outward presentments =============================
@Transactional
@@ -380,11 +394,25 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
return new CommandProcessingResultBuilder().withCommandId(command == null ? null : command.commandId()).build();
}
final long minId = pending.stream().mapToLong(OutwardClearingPresentment::getId).min().orElseThrow();
final String batchReference = "CTS-OUT-BATCH-" + DateUtils.getBusinessLocalDate() + "-" + minId;
sendOutwardBatchInternal(pending, batchReference);
log.info("Sent pending outward batch {} to NPCI ({} cheques, {} skipped for missing images)", batchReference, pending.size(),
skipped.size());
// one file per grid zone, not one file for the whole batch — a shared batchHeader.gridZoneCode is only
// valid if every instrument in it actually belongs to that zone, so cheques must be grouped by zone
// before being handed to sendOutwardBatchInternal rather than all bundled under the first zone found
final Map<String, List<OutwardClearingPresentment>> byZone = new LinkedHashMap<>();
for (final OutwardClearingPresentment p : pending) {
final String zoneKey = p.getGridZoneCode() == null || p.getGridZoneCode().isBlank() ? "NG" : p.getGridZoneCode();
byZone.computeIfAbsent(zoneKey, k -> new ArrayList<>()).add(p);
}
for (final Map.Entry<String, List<OutwardClearingPresentment>> zoneEntry : byZone.entrySet()) {
final List<OutwardClearingPresentment> zoneItems = zoneEntry.getValue();
final long minId = zoneItems.stream().mapToLong(OutwardClearingPresentment::getId).min().orElseThrow();
final String batchReference = "CTS-OUT-BATCH-" + DateUtils.getBusinessLocalDate() + "-" + zoneEntry.getKey() + "-" + minId;
sendOutwardBatchInternal(zoneItems, batchReference);
log.info("Sent pending outward batch {} to NPCI ({} cheques, grid zone {})", batchReference, zoneItems.size(),
zoneEntry.getKey());
}
log.info("Send-pending-batch run sent {} outward presentment(s) across {} grid zone batch(es), {} skipped for missing images",
pending.size(), byZone.size(), skipped.size());
return new CommandProcessingResultBuilder().withCommandId(command == null ? null : command.commandId())
.withEntityId((long) pending.size()).build();
}
@@ -433,4 +433,6 @@
<include file="parts/0410_npci_inward_push_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0411_inward_item_source_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0414_inward_item_full_source_details.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
New command on INWARDCLEARINGITEM: the clerk's manual signature-verification decision, required
before an item can be PASSed (see InwardClearingItem.pass()). Mirrors the PASS/RETURN permission
rows seeded in 0395_clearing_permissions.xml.
-->
<changeSet id="0413-inward-item-verify-signature-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'VERIFY_SIGNATURE_INWARDCLEARINGITEM', 'INWARDCLEARINGITEM', 'VERIFY_SIGNATURE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'VERIFY_SIGNATURE_INWARDCLEARINGITEM');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A field-by-field audit of the outward-CBS -> NPCI -> inward-CBS pipeline found several fields that
arrive in every NPCI instrument package but were silently dropped (read, or not even read, then
discarded) instead of being persisted on the inward item:
- drawerAccountNumberMasked: the drawee (this bank's own customer) account number, masked, that
NpciInwardPushService now also uses to disambiguate colliding cheque-leaf serials — previously
used transiently and discarded, now kept for audit trail.
- depositAccountNumber: the depositing (collecting-bank) customer's own account identifier.
- instrumentType / instrumentTypeDescription: the CTS instrument-type code carried on the wire.
- draweeBankName: the receiving bank's own name as asserted by the sender, for audit cross-check.
- iqaSubChecks: the per-check IQA breakdown (skew/noise/legibility/completeness), previously only
the rolled-up iqaStatus/iqaDetail survived.
Additive; safe no-op where applied.
-->
<changeSet id="0414-inward-item-full-source-details" author="CBS">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_inward_clearing_item"/>
<not><columnExists tableName="m_inward_clearing_item" columnName="drawer_account_number_masked"/></not>
</and>
</preConditions>
<addColumn tableName="m_inward_clearing_item">
<column name="drawer_account_number_masked" type="VARCHAR(40)"/>
<column name="deposit_account_number" type="VARCHAR(40)"/>
<column name="instrument_type" type="VARCHAR(10)"/>
<column name="instrument_type_description" type="VARCHAR(100)"/>
<column name="drawee_bank_name" type="VARCHAR(150)"/>
<column name="iqa_sub_checks" type="VARCHAR(300)"/>
</addColumn>
</changeSet>
</databaseChangeLog>