From ab5666c0f4776792d59b574b9f52771ac9b485fa Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Wed, 22 Jul 2026 12:30:07 +0530 Subject: [PATCH] feat(ctsclearing): signature-verification gate and full NPCI field capture for inward cheques MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../service/CommandWrapperBuilder.java | 8 ++ .../domain/ChequeLeafRepository.java | 7 ++ .../npci/service/NpciInwardPushService.java | 107 +++++++++++++++--- .../api/CtsClearingApiConstants.java | 1 + .../api/InwardClearingItemApiResource.java | 6 +- .../data/InwardClearingItemData.java | 6 + .../domain/InwardClearingItem.java | 53 +++++++++ ...rdClearingItemSignatureCommandHandler.java | 42 +++++++ .../CtsClearingReadPlatformServiceImpl.java | 12 +- .../CtsClearingWritePlatformService.java | 3 + .../CtsClearingWritePlatformServiceImpl.java | 38 ++++++- .../db/changelog/tenant/changelog-tenant.xml | 2 + ...nward_item_verify_signature_permission.xml | 20 ++++ .../0414_inward_item_full_source_details.xml | 38 +++++++ 14 files changed, 319 insertions(+), 24 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/handler/VerifyInwardClearingItemSignatureCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0413_inward_item_verify_signature_permission.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0414_inward_item_full_source_details.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index c7cf9d7ea..685cacb8c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -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"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeLeafRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeLeafRepository.java index 23443e58f..c34c16820 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeLeafRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeLeafRepository.java @@ -26,6 +26,13 @@ public interface ChequeLeafRepository extends JpaRepository { Optional 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 findAllByLeafSerialNo(Long leafSerialNo); + List findByChequeBookIdOrderByLeafSeqNoAsc(Long chequeBookId); List findByChequeBookIdAndLeafSeqNoBetweenOrderByLeafSeqNoAsc(Long chequeBookId, Integer fromSeqNo, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java index cce25dbf4..f6ae6838f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java @@ -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 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 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")) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/CtsClearingApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/CtsClearingApiConstants.java index 3b469e30a..d8c3bc4b9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/CtsClearingApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/CtsClearingApiConstants.java @@ -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"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/InwardClearingItemApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/InwardClearingItemApiResource.java index 55b22bfd1..6e70d31be 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/InwardClearingItemApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/api/InwardClearingItemApiResource.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/data/InwardClearingItemData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/data/InwardClearingItemData.java index dc9842aca..33573722f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/data/InwardClearingItemData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/data/InwardClearingItemData.java @@ -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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/domain/InwardClearingItem.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/domain/InwardClearingItem.java index 7232e2e2c..69bc78bf0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/domain/InwardClearingItem.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/domain/InwardClearingItem.java @@ -109,6 +109,30 @@ public class InwardClearingItem extends AbstractPersistableCustom { @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 { 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 { 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 { 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/handler/VerifyInwardClearingItemSignatureCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/handler/VerifyInwardClearingItemSignatureCommandHandler.java new file mode 100644 index 000000000..321b199bb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/handler/VerifyInwardClearingItemSignatureCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingReadPlatformServiceImpl.java index 42dda3647..2e793fe39 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingReadPlatformServiceImpl.java @@ -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(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformService.java index 8e0344196..ecbb0b289 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformService.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java index 13547eb95..aa91fbebe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java @@ -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> 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> zoneEntry : byZone.entrySet()) { + final List 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(); } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 7f6012d06..f59a3be7a 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -433,4 +433,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0413_inward_item_verify_signature_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0413_inward_item_verify_signature_permission.xml new file mode 100644 index 000000000..9a29adeee --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0413_inward_item_verify_signature_permission.xml @@ -0,0 +1,20 @@ + + + + + + + + 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'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0414_inward_item_full_source_details.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0414_inward_item_full_source_details.xml new file mode 100644 index 000000000..69ecec6e7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0414_inward_item_full_source_details.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + +