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 @@ + + + + + + + + + + + + + + + + + + + + + + +