feat(clearing): add CTS/NACH clearing modules with real NPCI integration
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract 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

- Inward/outward CTS clearing (capture, validate, settle, dishonour), positive pay,
  settlement/reconciliation, and NACH clearing modules
- Real, separate NPCI clearing-house integration: hybrid RSA+AES envelope crypto,
  bank self-registration, outward batch submission, and an inward push webhook
- Outward clearing is now session-batched: a 2-hourly scheduled job (plus the
  existing manual "Send Pending Batch" fallback) bundles CAPTURED cheques instead
  of sending them one at a time
- Batch-summary read APIs and date/text filters across inward batches, outward
  presentments, and outward batches

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-21 15:46:52 +05:30
co-authored by Claude Sonnet 5
parent 383dfdc6e9
commit b433edfdfe
162 changed files with 12826 additions and 63186 deletions
@@ -231,6 +231,176 @@ public class CommandWrapperBuilder {
return this;
}
// ===== Clearing (CTS + NACH) command wrappers =====
public CommandWrapperBuilder createClearingSettlementBatch() {
this.actionName = "CREATE";
this.entityName = "CLEARINGSETTLEMENTBATCH";
this.entityId = null;
this.href = "/clearing/settlement-batches";
return this;
}
public CommandWrapperBuilder postClearingSettlementBatch(final Long settlementBatchId) {
this.actionName = "POST";
this.entityName = "CLEARINGSETTLEMENTBATCH";
this.entityId = settlementBatchId;
this.href = "/clearing/settlement-batches/" + settlementBatchId;
return this;
}
public CommandWrapperBuilder rejectClearingSettlementBatch(final Long settlementBatchId) {
this.actionName = "REJECT";
this.entityName = "CLEARINGSETTLEMENTBATCH";
this.entityId = settlementBatchId;
this.href = "/clearing/settlement-batches/" + settlementBatchId;
return this;
}
public CommandWrapperBuilder createClearingReconciliation() {
this.actionName = "CREATE";
this.entityName = "CLEARINGRECONCILIATION";
this.entityId = null;
this.href = "/clearing/reconciliations";
return this;
}
public CommandWrapperBuilder createInwardClearingBatch() {
this.actionName = "CREATE";
this.entityName = "INWARDCLEARINGBATCH";
this.entityId = null;
this.href = "/ctsclearing/inward-batches";
return this;
}
public CommandWrapperBuilder validateInwardClearingBatch(final Long batchId) {
this.actionName = "VALIDATE";
this.entityName = "INWARDCLEARINGBATCH";
this.entityId = batchId;
this.href = "/ctsclearing/inward-batches/" + batchId;
return this;
}
public CommandWrapperBuilder passInwardClearingItem(final Long itemId) {
this.actionName = "PASS";
this.entityName = "INWARDCLEARINGITEM";
this.entityId = itemId;
this.href = "/ctsclearing/inward-items/" + itemId;
return this;
}
public CommandWrapperBuilder returnInwardClearingItem(final Long itemId) {
this.actionName = "RETURN";
this.entityName = "INWARDCLEARINGITEM";
this.entityId = itemId;
this.href = "/ctsclearing/inward-items/" + itemId;
return this;
}
public CommandWrapperBuilder createPositivePayRegistration() {
this.actionName = "CREATE";
this.entityName = "POSITIVEPAYREGISTRATION";
this.entityId = null;
this.href = "/ctsclearing/positive-pay";
return this;
}
public CommandWrapperBuilder cancelPositivePayRegistration(final Long registrationId) {
this.actionName = "CANCEL";
this.entityName = "POSITIVEPAYREGISTRATION";
this.entityId = registrationId;
this.href = "/ctsclearing/positive-pay/" + registrationId;
return this;
}
public CommandWrapperBuilder sendPendingOutwardClearingBatch() {
this.actionName = "SENDBATCH";
this.entityName = "OUTWARDCLEARINGPRESENTMENT";
this.entityId = null;
this.href = "/ctsclearing/outward-presentments/send-pending-batch";
return this;
}
public CommandWrapperBuilder createOutwardClearingPresentment() {
this.actionName = "CREATE";
this.entityName = "OUTWARDCLEARINGPRESENTMENT";
this.entityId = null;
this.href = "/ctsclearing/outward-presentments";
return this;
}
public CommandWrapperBuilder sendOutwardClearingPresentment(final Long presentmentId) {
this.actionName = "SEND";
this.entityName = "OUTWARDCLEARINGPRESENTMENT";
this.entityId = presentmentId;
this.href = "/ctsclearing/outward-presentments/" + presentmentId;
return this;
}
public CommandWrapperBuilder settleOutwardClearingPresentment(final Long presentmentId) {
this.actionName = "SETTLE";
this.entityName = "OUTWARDCLEARINGPRESENTMENT";
this.entityId = presentmentId;
this.href = "/ctsclearing/outward-presentments/" + presentmentId;
return this;
}
public CommandWrapperBuilder dishonourOutwardClearingPresentment(final Long presentmentId) {
this.actionName = "DISHONOUR";
this.entityName = "OUTWARDCLEARINGPRESENTMENT";
this.entityId = presentmentId;
this.href = "/ctsclearing/outward-presentments/" + presentmentId;
return this;
}
public CommandWrapperBuilder createNachMandate() {
this.actionName = "CREATE";
this.entityName = "NACHMANDATE";
this.entityId = null;
this.href = "/nachclearing/mandates";
return this;
}
public CommandWrapperBuilder cancelNachMandate(final Long mandateId) {
this.actionName = "CANCEL";
this.entityName = "NACHMANDATE";
this.entityId = mandateId;
this.href = "/nachclearing/mandates/" + mandateId;
return this;
}
public CommandWrapperBuilder createNachBatch() {
this.actionName = "CREATE";
this.entityName = "NACHBATCH";
this.entityId = null;
this.href = "/nachclearing/batches";
return this;
}
public CommandWrapperBuilder processNachBatch(final Long batchId) {
this.actionName = "PROCESS";
this.entityName = "NACHBATCH";
this.entityId = batchId;
this.href = "/nachclearing/batches/" + batchId;
return this;
}
public CommandWrapperBuilder passNachTransaction(final Long transactionId) {
this.actionName = "PASS";
this.entityName = "NACHTRANSACTION";
this.entityId = transactionId;
this.href = "/nachclearing/transactions/" + transactionId;
return this;
}
public CommandWrapperBuilder returnNachTransaction(final Long transactionId) {
this.actionName = "RETURN";
this.entityName = "NACHTRANSACTION";
this.entityId = transactionId;
this.href = "/nachclearing/transactions/" + transactionId;
return this;
}
public CommandWrapperBuilder updateCreditBureau() {
this.actionName = "UPDATE";
this.entityName = "ORGANISATIONCREDITBUREAU";
@@ -64,6 +64,7 @@ public enum JobName {
IMPS_DEEMED_RECONCILE("IMPS DEEMED Reconciliation"), //
NEFT_BATCH_PROCESS("NEFT Batch Process"), //
VERIFICATION_TOKEN_CLEANUP("Verification Token Cleanup"), //
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
; //
private final String name;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -154,6 +154,10 @@ public class SecurityConfig {
// a Fineract Bearer token; security is enforced at the JWT envelope
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
// packages here without a Fineract Bearer token; security is enforced by the
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
// no Fineract auth required.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
@@ -0,0 +1,33 @@
/**
* 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.clearing.api;
public final class ClearingApiConstants {
private ClearingApiConstants() {}
// entity names — must match the CommandWrapperBuilder entityName + the seeded permissions
public static final String CLEARINGRETURNREASONCODE = "CLEARINGRETURNREASONCODE";
public static final String CLEARINGSETTLEMENTBATCH = "CLEARINGSETTLEMENTBATCH";
public static final String CLEARINGRECONCILIATION = "CLEARINGRECONCILIATION";
// resource names used for validation error scoping
public static final String RESOURCE_SETTLEMENT_BATCH = "clearingsettlementbatch";
public static final String RESOURCE_RECONCILIATION = "clearingreconciliation";
}
@@ -0,0 +1,70 @@
/**
* 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.clearing.api;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
public enum ClearingApiJsonInputParams {
// settlement batch create
CLEARING_TYPE("clearingType"), //
DIRECTION("direction"), //
SETTLEMENT_DATE("settlementDate"), //
SOURCE_REFERENCE("sourceReference"), //
SOURCE_BATCH_ID("sourceBatchId"), //
// reconciliation create
RECONCILIATION_DATE("reconciliationDate"), //
CBS_TOTAL("cbsTotal"), //
CLEARING_HOUSE_TOTAL("clearingHouseTotal"), //
DETAIL("detail"), //
// settlement reject
REASON("reason"), //
// common — the web client always sends these on command payloads
LOCALE("locale"), //
DATE_FORMAT("dateFormat"); //
private final String value;
ClearingApiJsonInputParams(final String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
public static Set<String> settlementBatchCreateParams() {
return toSet(CLEARING_TYPE, DIRECTION, SETTLEMENT_DATE, SOURCE_REFERENCE, SOURCE_BATCH_ID, LOCALE, DATE_FORMAT);
}
public static Set<String> reconciliationParams() {
return toSet(CLEARING_TYPE, RECONCILIATION_DATE, SOURCE_REFERENCE, CBS_TOTAL, CLEARING_HOUSE_TOTAL, DETAIL, LOCALE, DATE_FORMAT);
}
public static Set<String> rejectSettlementParams() {
return toSet(REASON, LOCALE, DATE_FORMAT);
}
private static Set<String> toSet(final ClearingApiJsonInputParams... params) {
return Arrays.stream(params).map(ClearingApiJsonInputParams::getValue).collect(Collectors.toCollection(HashSet::new));
}
}
@@ -0,0 +1,84 @@
/**
* 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.clearing.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.clearing.data.ClearingReconciliationData;
import org.apache.fineract.portfolio.clearing.service.ClearingReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clearing/reconciliations")
@Component
@Tag(name = "Clearing Reconciliation", description = "Day-end reconciliation of the CBS ledger against the clearing house's "
+ "settlement report, for CTS and NACH.")
@RequiredArgsConstructor
public class ClearingReconciliationApiResource {
private final PlatformSecurityContext context;
private final ClearingReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List reconciliation runs")
public Page<ClearingReconciliationData> retrieveAll(@QueryParam("clearingType") final String clearingType,
@QueryParam("offset") @DefaultValue("0") final Integer offset,
@QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClearingApiConstants.CLEARINGRECONCILIATION);
final int safeLimit = Math.min(limit, 200);
return this.readPlatformService.retrieveReconciliations(clearingType, offset, safeLimit);
}
@GET
@Path("{id}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a reconciliation run")
public ClearingReconciliationData retrieveOne(@PathParam("id") @Parameter(description = "id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(ClearingApiConstants.CLEARINGRECONCILIATION);
return this.readPlatformService.retrieveReconciliation(id);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a reconciliation",
description = "Body: { clearingType, reconciliationDate, sourceReference, cbsTotal, clearingHouseTotal, detail }.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createClearingReconciliation().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,55 @@
/**
* 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.clearing.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.clearing.data.ClearingReturnReasonCodeData;
import org.apache.fineract.portfolio.clearing.service.ClearingReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clearing/return-reason-codes")
@Component
@Tag(name = "Clearing Return Reason Codes", description = "Standardized return/dishonour codes shared by the CTS and NACH "
+ "clearing modules.")
@RequiredArgsConstructor
public class ClearingReturnReasonCodeApiResource {
private final PlatformSecurityContext context;
private final ClearingReadPlatformService readPlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List clearing return reason codes",
description = "Optionally filtered by ?appliesTo=CTS|NACH (codes marked BOTH are always included).")
public List<ClearingReturnReasonCodeData> retrieveAll(
@QueryParam("appliesTo") @Parameter(description = "CTS | NACH") final String appliesTo) {
this.context.authenticatedUser().validateHasReadPermission(ClearingApiConstants.CLEARINGRETURNREASONCODE);
return this.readPlatformService.retrieveReturnReasonCodes(appliesTo);
}
}
@@ -0,0 +1,105 @@
/**
* 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.clearing.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.clearing.data.ClearingSettlementBatchData;
import org.apache.fineract.portfolio.clearing.service.ClearingReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clearing/settlement-batches")
@Component
@Tag(name = "Clearing Settlement Batches", description = "Net-settlement batches for CTS and NACH clearing sessions. "
+ "Posting a batch (a different user than the preparer) writes the summary GL entry.")
@RequiredArgsConstructor
public class ClearingSettlementBatchApiResource {
private final PlatformSecurityContext context;
private final ClearingReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List settlement batches")
public Page<ClearingSettlementBatchData> retrieveAll(@QueryParam("clearingType") final String clearingType,
@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final Integer offset,
@QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClearingApiConstants.CLEARINGSETTLEMENTBATCH);
final int safeLimit = Math.min(limit, 200);
return this.readPlatformService.retrieveSettlementBatches(clearingType, status, offset, safeLimit);
}
@GET
@Path("{id}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a settlement batch")
public ClearingSettlementBatchData retrieveOne(@PathParam("id") @Parameter(description = "id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(ClearingApiConstants.CLEARINGSETTLEMENTBATCH);
return this.readPlatformService.retrieveSettlementBatch(id);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Prepare a settlement batch",
description = "Nets the passed/returned totals of a decisioned source batch. Body: { clearingType, sourceBatchId }.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createClearingSettlementBatch().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a command on a settlement batch", description = "Supported commands: post, reject.")
public CommandProcessingResult handleCommand(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("command") @Parameter(description = "post | reject") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
final CommandWrapper request;
if ("post".equalsIgnoreCase(commandParam)) {
request = builder.postClearingSettlementBatch(id).build();
} else if ("reject".equalsIgnoreCase(commandParam)) {
request = builder.rejectClearingSettlementBatch(id).build();
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,44 @@
/**
* 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.clearing.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class ClearingReconciliationData {
private final Long id;
private final String clearingType;
private final LocalDate reconciliationDate;
private final String sourceReference;
private final BigDecimal cbsTotal;
private final BigDecimal clearingHouseTotal;
private final BigDecimal difference;
private final String status;
private final String detail;
private final Long runBy;
private final OffsetDateTime createdOn;
}
@@ -0,0 +1,36 @@
/**
* 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.clearing.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class ClearingReturnReasonCodeData {
private final Long id;
private final String code;
private final String description;
private final String category;
private final String appliesTo;
private final boolean active;
}
@@ -0,0 +1,53 @@
/**
* 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.clearing.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class ClearingSettlementBatchData {
private final Long id;
private final String clearingType;
private final String direction;
private final LocalDate settlementDate;
private final String sourceReference;
private final Long sourceBatchId;
private final BigDecimal totalPassedAmount;
private final BigDecimal totalReturnedAmount;
private final BigDecimal netAmount;
private final Integer passedCount;
private final Integer returnedCount;
private final Long officeId;
private final String currencyCode;
private final String glTransactionId;
private final String status;
private final Long preparedBy;
private final OffsetDateTime preparedOn;
private final Long postedBy;
private final OffsetDateTime postedOn;
private final String rejectionReason;
}
@@ -0,0 +1,83 @@
/**
* 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.clearing.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A simulated customer notification (spec section 10.1 "Notification service"). Clearing decisioning writes a
* row here on pass/return/settlement; there is no real SMS/email gateway wired in, so {@code status} defaults
* to {@code SIMULATED}. This gives an auditable, demoable log without pretending to send real messages.
*/
@Entity
@Table(name = "m_clearing_notification")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ClearingNotification extends AbstractPersistableCustom<Long> {
@Column(name = "channel", nullable = false, length = 10)
private String channel;
@Column(name = "recipient", length = 150)
private String recipient;
@Column(name = "event", nullable = false, length = 30)
private String event;
@Column(name = "clearing_type", length = 10)
private String clearingType;
@Column(name = "ref_type", length = 40)
private String refType;
@Column(name = "ref_id")
private Long refId;
@Column(name = "message", nullable = false, length = 1000)
private String message;
@Column(name = "status", nullable = false, length = 15)
private String status;
@Column(name = "created_on")
private OffsetDateTime createdOn;
public static ClearingNotification simulated(final String channel, final String recipient, final String event,
final String clearingType, final String refType, final Long refId, final String message) {
final ClearingNotification n = new ClearingNotification();
n.channel = channel;
n.recipient = recipient;
n.event = event;
n.clearingType = clearingType;
n.refType = refType;
n.refId = refId;
n.message = message;
n.status = "SIMULATED";
n.createdOn = DateUtils.getOffsetDateTimeOfTenant();
return n;
}
}
@@ -0,0 +1,25 @@
/**
* 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.clearing.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClearingNotificationRepository extends JpaRepository<ClearingNotification, Long> {
}
@@ -0,0 +1,89 @@
/**
* 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.clearing.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A day-end reconciliation run (spec sections 4.11 &amp; 8.9): compares the CBS's own settled total for a
* session against the figure reported by the clearing house, and records whether they matched. In this
* simulation the "clearing house report total" is supplied by the operator (there is no external NPCI feed).
*/
@Entity
@Table(name = "m_clearing_reconciliation")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ClearingReconciliation extends AbstractPersistableCustom<Long> {
@Column(name = "clearing_type", nullable = false, length = 10)
private String clearingType;
@Column(name = "reconciliation_date", nullable = false)
private LocalDate reconciliationDate;
@Column(name = "source_reference", length = 100)
private String sourceReference;
@Column(name = "cbs_total", nullable = false, scale = 6, precision = 19)
private BigDecimal cbsTotal;
@Column(name = "clearing_house_total", nullable = false, scale = 6, precision = 19)
private BigDecimal clearingHouseTotal;
@Column(name = "difference", nullable = false, scale = 6, precision = 19)
private BigDecimal difference;
@Column(name = "status", nullable = false, length = 15)
private String status;
@Column(name = "detail", length = 1000)
private String detail;
@Column(name = "run_by")
private Long runBy;
@Column(name = "created_on")
private OffsetDateTime createdOn;
public static ClearingReconciliation run(final String clearingType, final LocalDate reconciliationDate, final String sourceReference,
final BigDecimal cbsTotal, final BigDecimal clearingHouseTotal, final String detail, final Long runBy) {
final ClearingReconciliation rec = new ClearingReconciliation();
rec.clearingType = clearingType;
rec.reconciliationDate = reconciliationDate;
rec.sourceReference = sourceReference;
rec.cbsTotal = cbsTotal == null ? BigDecimal.ZERO : cbsTotal;
rec.clearingHouseTotal = clearingHouseTotal == null ? BigDecimal.ZERO : clearingHouseTotal;
rec.difference = rec.cbsTotal.subtract(rec.clearingHouseTotal);
rec.status = rec.difference.signum() == 0 ? "MATCHED" : "MISMATCH";
rec.detail = detail;
rec.runBy = runBy;
rec.createdOn = DateUtils.getOffsetDateTimeOfTenant();
return rec;
}
}
@@ -0,0 +1,25 @@
/**
* 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.clearing.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClearingReconciliationRepository extends JpaRepository<ClearingReconciliation, Long> {
}
@@ -0,0 +1,56 @@
/**
* 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.clearing.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A standardized return/dishonour reason code shared by CTS and NACH decisioning (spec section 14). Kept as a
* seeded reference table rather than a Java enum so the set is admin-visible and extendable without a code
* change, and so decision rows can carry a stable FK-by-code.
*/
@Entity
@Table(name = "m_clearing_return_reason_code")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ClearingReturnReasonCode extends AbstractPersistableCustom<Long> {
@Column(name = "code", nullable = false, length = 10, unique = true)
private String code;
@Column(name = "description", nullable = false, length = 200)
private String description;
/** FINANCIAL / SIGNATURE / ACCOUNT / INSTRUMENT / MANDATE / TECHNICAL / OTHER — for grouping in the UI. */
@Column(name = "category", nullable = false, length = 20)
private String category;
/** CTS / NACH / BOTH — which clearing flow this code is valid for. */
@Column(name = "applies_to", nullable = false, length = 10)
private String appliesTo;
@Column(name = "is_active", nullable = false)
private boolean active;
}
@@ -0,0 +1,27 @@
/**
* 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.clearing.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClearingReturnReasonCodeRepository extends JpaRepository<ClearingReturnReasonCode, Long> {
Optional<ClearingReturnReasonCode> findByCodeIgnoreCase(String code);
}
@@ -0,0 +1,166 @@
/**
* 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.clearing.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A net-settlement batch over one clearing session (spec sections 7 &amp; 8, "Settlement"). Shared by CTS and
* NACH via {@code clearingType}. Nets the passed vs returned amounts of a decisioned source batch and, when
* posted, writes a summary GL entry to the clearing suspense/settlement accounts.
*
* <p>
* Posting is the one two-person-control point in the clearing flow: {@code preparedBy} computes the batch, a
* different user {@code postedBy} authorises the GL posting. {@link #isPreparedBy(Long)} enforces this in code
* (not only via the {@code can_maker_checker} permission flag) because an {@code ALL_FUNCTIONS} role would
* otherwise bypass the native maker-checker hold in this tenant.
* </p>
*/
@Entity
@Table(name = "m_clearing_settlement_batch")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ClearingSettlementBatch extends AbstractPersistableCustom<Long> {
@Column(name = "clearing_type", nullable = false, length = 10)
private String clearingType;
@Column(name = "settlement_date", nullable = false)
private LocalDate settlementDate;
@Column(name = "direction", nullable = false, length = 10)
private String direction;
@Column(name = "source_reference", length = 100)
private String sourceReference;
@Column(name = "source_batch_id")
private Long sourceBatchId;
@Column(name = "total_passed_amount", nullable = false, scale = 6, precision = 19)
private BigDecimal totalPassedAmount;
@Column(name = "total_returned_amount", nullable = false, scale = 6, precision = 19)
private BigDecimal totalReturnedAmount;
@Column(name = "net_amount", nullable = false, scale = 6, precision = 19)
private BigDecimal netAmount;
@Column(name = "passed_count", nullable = false)
private Integer passedCount;
@Column(name = "returned_count", nullable = false)
private Integer returnedCount;
@Column(name = "office_id")
private Long officeId;
@Column(name = "currency_code", length = 3)
private String currencyCode;
@Column(name = "gl_transaction_id", length = 100)
private String glTransactionId;
@Column(name = "status", nullable = false, length = 15)
private String status;
@Column(name = "prepared_by")
private Long preparedBy;
@Column(name = "prepared_on")
private OffsetDateTime preparedOn;
@Column(name = "posted_by")
private Long postedBy;
@Column(name = "posted_on")
private OffsetDateTime postedOn;
@Column(name = "rejection_reason", length = 500)
private String rejectionReason;
@Column(name = "created_on")
private OffsetDateTime createdOn;
public static ClearingSettlementBatch prepare(final String clearingType, final String direction, final LocalDate settlementDate,
final String sourceReference, final Long sourceBatchId, final BigDecimal totalPassed, final BigDecimal totalReturned,
final Integer passedCount, final Integer returnedCount, final Long officeId, final String currencyCode,
final Long preparedBy) {
final ClearingSettlementBatch batch = new ClearingSettlementBatch();
batch.clearingType = clearingType;
batch.direction = direction;
batch.settlementDate = settlementDate;
batch.sourceReference = sourceReference;
batch.sourceBatchId = sourceBatchId;
batch.totalPassedAmount = totalPassed == null ? BigDecimal.ZERO : totalPassed;
batch.totalReturnedAmount = totalReturned == null ? BigDecimal.ZERO : totalReturned;
batch.netAmount = batch.totalPassedAmount.subtract(batch.totalReturnedAmount);
batch.passedCount = passedCount == null ? 0 : passedCount;
batch.returnedCount = returnedCount == null ? 0 : returnedCount;
batch.officeId = officeId;
batch.currencyCode = currencyCode;
batch.status = ClearingSettlementBatchStatus.PENDING.name();
batch.preparedBy = preparedBy;
batch.preparedOn = DateUtils.getOffsetDateTimeOfTenant();
batch.createdOn = DateUtils.getOffsetDateTimeOfTenant();
return batch;
}
public boolean isPreparedBy(final Long userId) {
return this.preparedBy != null && this.preparedBy.equals(userId);
}
public boolean isPending() {
return ClearingSettlementBatchStatus.PENDING.name().equals(this.status);
}
private void assertPending() {
if (!isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.clearing.settlement.not.pending",
"Settlement batch " + getId() + " is not in PENDING status.", getId());
}
}
public void markPosted(final Long postedBy, final String glTransactionId) {
assertPending();
this.status = ClearingSettlementBatchStatus.POSTED.name();
this.postedBy = postedBy;
this.postedOn = DateUtils.getOffsetDateTimeOfTenant();
this.glTransactionId = glTransactionId;
}
public void reject(final Long rejectedBy, final String reason) {
assertPending();
this.status = ClearingSettlementBatchStatus.REJECTED.name();
this.postedBy = rejectedBy;
this.postedOn = DateUtils.getOffsetDateTimeOfTenant();
this.rejectionReason = reason;
}
}
@@ -0,0 +1,25 @@
/**
* 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.clearing.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClearingSettlementBatchRepository extends JpaRepository<ClearingSettlementBatch, Long> {
}
@@ -0,0 +1,38 @@
/**
* 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.clearing.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.clearing.exception.ClearingSettlementBatchNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ClearingSettlementBatchRepositoryWrapper {
private final ClearingSettlementBatchRepository repository;
public ClearingSettlementBatch findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new ClearingSettlementBatchNotFoundException(id));
}
public ClearingSettlementBatch saveAndFlush(final ClearingSettlementBatch batch) {
return this.repository.saveAndFlush(batch);
}
}
@@ -0,0 +1,26 @@
/**
* 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.clearing.domain;
public enum ClearingSettlementBatchStatus {
PENDING, //
POSTED, //
REJECTED; //
}
@@ -0,0 +1,28 @@
/**
* 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.clearing.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class ClearingReconciliationNotFoundException extends AbstractPlatformResourceNotFoundException {
public ClearingReconciliationNotFoundException(final Long id) {
super("error.msg.clearing.reconciliation.id.invalid", "Clearing reconciliation with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* 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.clearing.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class ClearingSettlementBatchNotFoundException extends AbstractPlatformResourceNotFoundException {
public ClearingSettlementBatchNotFoundException(final Long id) {
super("error.msg.clearing.settlement.id.invalid", "Clearing settlement batch with identifier " + id + " does not exist", id);
}
}
@@ -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.clearing.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.clearing.service.ClearingWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CLEARINGRECONCILIATION", action = "CREATE")
@RequiredArgsConstructor
public class CreateClearingReconciliationCommandHandler implements NewCommandSourceHandler {
private final ClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createReconciliation(command);
}
}
@@ -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.clearing.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.clearing.service.ClearingWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CLEARINGSETTLEMENTBATCH", action = "CREATE")
@RequiredArgsConstructor
public class CreateClearingSettlementBatchCommandHandler implements NewCommandSourceHandler {
private final ClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.prepareSettlement(command);
}
}
@@ -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.clearing.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.clearing.service.ClearingWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CLEARINGSETTLEMENTBATCH", action = "POST")
@RequiredArgsConstructor
public class PostClearingSettlementBatchCommandHandler implements NewCommandSourceHandler {
private final ClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.postSettlement(command.entityId(), command);
}
}
@@ -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.clearing.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.clearing.service.ClearingWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CLEARINGSETTLEMENTBATCH", action = "REJECT")
@RequiredArgsConstructor
public class RejectClearingSettlementBatchCommandHandler implements NewCommandSourceHandler {
private final ClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.rejectSettlement(command.entityId(), command);
}
}
@@ -0,0 +1,67 @@
/**
* 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.clearing.npci.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.clearing.npci.data.NpciInwardPushRequest;
import org.apache.fineract.portfolio.clearing.npci.service.NpciInwardPushService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Receiver-side endpoint the real, separate NPCI clearing-house service pushes routed packages to —
* mirrors {@code ImpsCbsApiResource}'s shape (no {@code PlatformSecurityContext}; NPCI calls this without
* a Fineract Bearer token, security is enforced by the envelope's RSA signature/encryption inside the
* handler instead). Whitelisted in {@code SecurityConfig} the same way the IMPS receiver is.
*/
@Path("/v1/npci")
@Component
@Tag(name = "NPCI Clearing House (Receiver)", description = "Inbound endpoint the real NPCI service pushes routed packages to.")
@RequiredArgsConstructor
public class NpciInwardPushApiResource {
private static final Logger log = LoggerFactory.getLogger(NpciInwardPushApiResource.class);
private final NpciInwardPushService pushService;
@POST
@Path("/inward-push")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "NPCI pushes a routed package destined for this bank")
public Response inwardPush(final NpciInwardPushRequest request) {
try {
this.pushService.receivePush(request);
return Response.ok(Map.of("status", "received")).build();
} catch (final Exception e) {
log.error("NPCI inward push handling failed for packageId={}: {}", request == null ? null : request.getId(), e.toString(), e);
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
}
@@ -0,0 +1,210 @@
/**
* 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.clearing.npci.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.security.PublicKey;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.fineract.portfolio.clearing.npci.crypto.NpciCryptoUtil;
import org.apache.fineract.portfolio.clearing.npci.crypto.NpciEnvelope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* Outbound calls from this CBS to the real, separate NPCI clearing-house service — registering this bank's
* own public key, fetching NPCI's public key, and submitting outward batches encrypted+signed for NPCI.
* Mirrors {@code ImpsSwitchClient}'s shape: a self-contained lazily-built {@link OkHttpClient}, {@code @Value}
* config, POST JSON / parse JSON.
*
* <p>Self-registration is lazy (on first outward send in this process) rather than an eager startup runner,
* since a background bean initializer running before this tenant's request-scoped context is fully
* established is fragile in a multi-tenant Fineract app — by the time an outward "send" command is being
* processed, the same tenant-scoped repository access already works correctly elsewhere in that request.
*/
@Service
public class NpciClearingClient {
private static final Logger log = LoggerFactory.getLogger(NpciClearingClient.class);
private static final MediaType JSON = MediaType.get("application/json");
@Value("${fineract.npci.base-url:}")
private String baseUrl;
@Value("${fineract.npci.bank-code:CBSHOME01}")
private String bankCode;
@Value("${fineract.npci.bank-name:CBSnexora Home Bank}")
private String bankName;
@Value("${fineract.npci.ifsc-prefix:CBSH}")
private String ifscPrefix;
@Value("${fineract.npci.inward-callback-url:}")
private String inwardCallbackUrl;
@Value("${fineract.npci.timeout-ms:10000}")
private int timeoutMs;
private final NpciCryptoUtil cryptoUtil;
private final ObjectMapper objectMapper = new ObjectMapper();
private OkHttpClient httpClient;
private volatile boolean registeredThisSession = false;
private volatile String cachedNpciPublicKeyPem;
public NpciClearingClient(final NpciCryptoUtil cryptoUtil) {
this.cryptoUtil = cryptoUtil;
}
private OkHttpClient client() {
if (this.httpClient == null) {
this.httpClient = new OkHttpClient.Builder().connectTimeout(Duration.ofMillis(this.timeoutMs))
.readTimeout(this.timeoutMs, TimeUnit.MILLISECONDS).writeTimeout(this.timeoutMs, TimeUnit.MILLISECONDS).build();
}
return this.httpClient;
}
/**
* Sends {@code ctsFileJson} to NPCI as {@code senderBankCode}'s outward batch: registers this bank with
* NPCI first if not already done this process, fetches NPCI's public key, encrypts+signs, POSTs, and
* returns NPCI's raw JSON response for storage/display. Throws on any failure — callers should let this
* roll back the enclosing transaction rather than mark anything "sent" that was never transmitted.
*/
public String submitOutwardBatch(final String ctsFileJson) {
requireBaseUrl();
ensureRegisteredWithNpci();
final PublicKey npciPublicKey = this.cryptoUtil.publicKeyFromPem(fetchNpciPublicKeyPem());
final NpciEnvelope envelope = this.cryptoUtil.encryptAndSignForNpci(ctsFileJson, npciPublicKey);
final Map<String, Object> body = new LinkedHashMap<>();
body.put("senderBankCode", this.bankCode);
body.put("envelope", envelope);
final String url = trimTrailingSlash(this.baseUrl) + "/clearing/outward";
log.info("Submitting outward batch to NPCI as {} -> POST {}", this.bankCode, url);
final String responseBody = post(url, body);
log.info("NPCI response for outward batch from {}: {}", this.bankCode, responseBody);
return responseBody;
}
/** NPCI's own public key, parsed and cached — used to verify signatures on anything NPCI sends this CBS. */
public PublicKey fetchNpciPublicKey() {
requireBaseUrl();
return this.cryptoUtil.publicKeyFromPem(fetchNpciPublicKeyPem());
}
private synchronized void ensureRegisteredWithNpci() {
if (this.registeredThisSession) {
return;
}
try {
final Map<String, Object> body = new LinkedHashMap<>();
body.put("bankCode", this.bankCode);
body.put("bankName", this.bankName);
body.put("ifscPrefix", this.ifscPrefix);
body.put("publicKeyPem", this.cryptoUtil.ownPublicKeyPem());
if (this.inwardCallbackUrl != null && !this.inwardCallbackUrl.isBlank()) {
body.put("inwardCallbackUrl", this.inwardCallbackUrl);
}
final String url = trimTrailingSlash(this.baseUrl) + "/banks";
log.info("Registering this bank ({}) with NPCI -> POST {}", this.bankCode, url);
post(url, body);
this.cryptoUtil.markRegisteredWithNpci();
this.registeredThisSession = true;
log.info("Registered with NPCI as {}", this.bankCode);
} catch (final RuntimeException e) {
log.warn("Could not register with NPCI (will retry on next send): {}", e.getMessage());
throw e;
}
}
private String fetchNpciPublicKeyPem() {
if (this.cachedNpciPublicKeyPem != null) {
return this.cachedNpciPublicKeyPem;
}
final String url = trimTrailingSlash(this.baseUrl) + "/npci/public-key";
try {
final Request request = new Request.Builder().url(url).get().build();
try (Response response = client().newCall(request).execute()) {
final String responseBody = bodyAsString(response.body());
if (!response.isSuccessful()) {
throw new IOException("NPCI public-key fetch returned HTTP " + response.code() + ": " + responseBody);
}
@SuppressWarnings("unchecked")
final Map<String, Object> parsed = this.objectMapper.readValue(responseBody, Map.class);
final String pem = (String) parsed.get("publicKeyPem");
if (pem == null || pem.isBlank()) {
throw new IllegalStateException("NPCI public-key response did not contain publicKeyPem");
}
this.cachedNpciPublicKeyPem = pem;
return pem;
}
} catch (final RuntimeException e) {
throw e;
} catch (final Exception e) {
throw new IllegalStateException("Failed to fetch NPCI's public key: " + e.getMessage(), e);
}
}
private String post(final String url, final Object body) {
try {
final String requestJson = this.objectMapper.writeValueAsString(body);
final Request request = new Request.Builder().url(url).post(RequestBody.create(requestJson, JSON)).build();
try (Response response = client().newCall(request).execute()) {
final String responseBody = bodyAsString(response.body());
if (!response.isSuccessful()) {
throw new IOException("NPCI call to " + url + " returned HTTP " + response.code() + ": " + responseBody);
}
return responseBody;
}
} catch (final RuntimeException e) {
throw e;
} catch (final Exception e) {
throw new IllegalStateException("NPCI call to " + url + " failed: " + e.getMessage(), e);
}
}
private void requireBaseUrl() {
if (this.baseUrl == null || this.baseUrl.isBlank()) {
throw new IllegalStateException("fineract.npci.base-url is not set");
}
}
private static String trimTrailingSlash(final String url) {
return url.replaceAll("/+$", "");
}
private static String bodyAsString(final ResponseBody body) throws IOException {
return body == null ? "" : body.string();
}
}
@@ -0,0 +1,221 @@
/**
* 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.clearing.npci.crypto;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import org.apache.fineract.portfolio.clearing.npci.domain.ClearingOwnIdentity;
import org.apache.fineract.portfolio.clearing.npci.domain.ClearingOwnIdentityRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Owns this CBS's own RSA key pair (generated once on first use, persisted in {@link ClearingOwnIdentity})
* and the hybrid RSA-2048-OAEP(SHA-256) + AES-256-GCM encrypt/sign scheme used to talk to the real,
* separate NPCI clearing-house service. Wire-compatible with NPCI's own {@code CryptoService} — same
* envelope shape, same algorithms — but this class only ever handles CBS's OWN key pair plus NPCI's
* public key; no other bank's key material passes through here.
*/
@Component
public class NpciCryptoUtil {
private static final Logger log = LoggerFactory.getLogger(NpciCryptoUtil.class);
private static final String RSA_ALGORITHM = "RSA";
private static final String RSA_CIPHER = "RSA/ECB/OAEPPadding";
private static final String AES_CIPHER = "AES/GCM/NoPadding";
private static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final int GCM_IV_LENGTH_BYTES = 12;
private final ClearingOwnIdentityRepository ownIdentityRepository;
private final SecureRandom secureRandom = new SecureRandom();
public NpciCryptoUtil(final ClearingOwnIdentityRepository ownIdentityRepository) {
this.ownIdentityRepository = ownIdentityRepository;
}
@Transactional
public ClearingOwnIdentity loadOrCreateOwnIdentity() {
return this.ownIdentityRepository.findFirstByOrderByIdAsc().orElseGet(() -> {
log.info("No CBS own key pair found for NPCI integration — generating a new RSA-2048 key pair (one-time)");
final KeyPair keyPair = generateRsaKeyPair();
final ClearingOwnIdentity identity = ClearingOwnIdentity.create(toPem(keyPair.getPublic()), toPem(keyPair.getPrivate()));
final ClearingOwnIdentity saved = this.ownIdentityRepository.saveAndFlush(identity);
log.info("CBS's own key pair generated and persisted (id={})", saved.getId());
return saved;
});
}
public String ownPublicKeyPem() {
return loadOrCreateOwnIdentity().getPublicKeyPem();
}
public PrivateKey ownPrivateKey() {
return privateKeyFromPem(loadOrCreateOwnIdentity().getPrivateKeyPem());
}
@Transactional
public void markRegisteredWithNpci() {
final ClearingOwnIdentity identity = loadOrCreateOwnIdentity();
identity.markRegisteredWithNpci();
this.ownIdentityRepository.saveAndFlush(identity);
}
public KeyPair generateRsaKeyPair() {
try {
final KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA_ALGORITHM);
generator.initialize(2048, this.secureRandom);
return generator.generateKeyPair();
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException("RSA not available", e);
}
}
public String toPem(final PublicKey key) {
return pemBlock("PUBLIC KEY", key.getEncoded());
}
public String toPem(final PrivateKey key) {
return pemBlock("PRIVATE KEY", key.getEncoded());
}
public PublicKey publicKeyFromPem(final String pem) {
try {
final byte[] der = Base64.getDecoder().decode(stripPem(pem));
final KeyFactory factory = KeyFactory.getInstance(RSA_ALGORITHM);
return factory.generatePublic(new X509EncodedKeySpec(der));
} catch (final GeneralSecurityException e) {
throw new IllegalArgumentException("Invalid public key PEM", e);
}
}
private PrivateKey privateKeyFromPem(final String pem) {
try {
final byte[] der = Base64.getDecoder().decode(stripPem(pem));
final KeyFactory factory = KeyFactory.getInstance(RSA_ALGORITHM);
return factory.generatePrivate(new PKCS8EncodedKeySpec(der));
} catch (final GeneralSecurityException e) {
throw new IllegalArgumentException("Invalid private key PEM", e);
}
}
/** Encrypts {@code plaintext} for {@code recipientPublicKey} (NPCI's) and signs the ciphertext with CBS's own private key. */
public NpciEnvelope encryptAndSignForNpci(final String plaintext, final PublicKey npciPublicKey) {
try {
final KeyGenerator aesGen = KeyGenerator.getInstance("AES");
aesGen.init(256, this.secureRandom);
final SecretKey aesKey = aesGen.generateKey();
final byte[] iv = new byte[GCM_IV_LENGTH_BYTES];
this.secureRandom.nextBytes(iv);
final Cipher aesCipher = Cipher.getInstance(AES_CIPHER);
aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
final byte[] ciphertext = aesCipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
final Cipher rsaCipher = Cipher.getInstance(RSA_CIPHER);
rsaCipher.init(Cipher.ENCRYPT_MODE, npciPublicKey, oaepSha256());
final byte[] encryptedKey = rsaCipher.doFinal(aesKey.getEncoded());
final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM);
signer.initSign(ownPrivateKey(), this.secureRandom);
signer.update(ciphertext);
final byte[] signature = signer.sign();
final Base64.Encoder b64 = Base64.getEncoder();
return new NpciEnvelope(b64.encodeToString(encryptedKey), b64.encodeToString(iv), b64.encodeToString(ciphertext),
b64.encodeToString(signature));
} catch (final GeneralSecurityException e) {
throw new IllegalStateException("Encryption for NPCI failed", e);
}
}
/** Decrypts an envelope addressed to this CBS with its own private key. Does not verify the signature. */
public String decryptOwn(final NpciEnvelope envelope) {
try {
final Base64.Decoder b64 = Base64.getDecoder();
final byte[] encryptedKey = b64.decode(envelope.getEncryptedKey());
final byte[] iv = b64.decode(envelope.getIv());
final byte[] ciphertext = b64.decode(envelope.getCiphertext());
final Cipher rsaCipher = Cipher.getInstance(RSA_CIPHER);
rsaCipher.init(Cipher.DECRYPT_MODE, ownPrivateKey(), oaepSha256());
final byte[] aesKeyBytes = rsaCipher.doFinal(encryptedKey);
final SecretKey aesKey = new SecretKeySpec(aesKeyBytes, "AES");
final Cipher aesCipher = Cipher.getInstance(AES_CIPHER);
aesCipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
final byte[] plaintext = aesCipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
} catch (final GeneralSecurityException e) {
throw new IllegalStateException("Decryption failed", e);
}
}
/** Verifies the envelope's signature (over the ciphertext bytes) was made by {@code senderPublicKey} (NPCI's). */
public boolean verify(final NpciEnvelope envelope, final PublicKey senderPublicKey) {
try {
final Base64.Decoder b64 = Base64.getDecoder();
final byte[] ciphertext = b64.decode(envelope.getCiphertext());
final byte[] signature = b64.decode(envelope.getSignature());
final Signature verifier = Signature.getInstance(SIGNATURE_ALGORITHM);
verifier.initVerify(senderPublicKey);
verifier.update(ciphertext);
return verifier.verify(signature);
} catch (final GeneralSecurityException e) {
return false;
}
}
private static OAEPParameterSpec oaepSha256() {
return new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT);
}
private static String pemBlock(final String label, final byte[] der) {
final String base64 = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.UTF_8)).encodeToString(der);
return "-----BEGIN " + label + "-----\n" + base64 + "\n-----END " + label + "-----\n";
}
private static String stripPem(final String pem) {
return pem.replaceAll("-----BEGIN [A-Z ]+-----", "").replaceAll("-----END [A-Z ]+-----", "").replaceAll("\\s", "");
}
}
@@ -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.clearing.npci.crypto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Wire envelope shared with the NPCI clearing-house service: an AES key wrapped with the recipient's RSA
* public key, the AES-GCM IV, the AES-GCM ciphertext, and a signature over the ciphertext made with the
* sender's RSA private key. All four fields are base64. Field names must match NPCI's own {@code EnvelopeDto}
* exactly (encryptedKey/iv/ciphertext/signature) — this is a shared wire contract, not just an internal shape.
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class NpciEnvelope {
private String encryptedKey;
private String iv;
private String ciphertext;
private String signature;
}
@@ -0,0 +1,44 @@
/**
* 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.clearing.npci.data;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.fineract.portfolio.clearing.npci.crypto.NpciEnvelope;
/**
* Wire shape NPCI pushes to this CBS's inward-callback URL — matches NPCI's own {@code RoutedPackageView}
* field-for-field (id/sourceBankCode/destinationBankCode/sourceBatchReference/status/plaintextPackageJson/
* envelope/createdOn); only {@code envelope} is actually cryptographically meaningful, the rest is metadata.
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class NpciInwardPushRequest {
private Long id;
private String sourceBankCode;
private String destinationBankCode;
private String sourceBatchReference;
private String status;
private String plaintextPackageJson;
private NpciEnvelope envelope;
private String createdOn;
}
@@ -0,0 +1,66 @@
/**
* 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.clearing.npci.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* This CBS's own RSA key pair for talking to the real, separate NPCI clearing-house service — a singleton
* row, generated once on first use. {@code privateKeyPem} never leaves this process (no API exposes it);
* only {@code publicKeyPem} is ever sent to NPCI, via bank self-registration.
*/
@Entity
@Table(name = "m_clearing_own_identity")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ClearingOwnIdentity extends AbstractPersistableCustom<Long> {
@Column(name = "public_key_pem", nullable = false, columnDefinition = "TEXT")
private String publicKeyPem;
@Column(name = "private_key_pem", nullable = false, columnDefinition = "TEXT")
private String privateKeyPem;
@Column(name = "registered_with_npci", nullable = false)
private boolean registeredWithNpci;
@Column(name = "created_on")
private OffsetDateTime createdOn;
public static ClearingOwnIdentity create(final String publicKeyPem, final String privateKeyPem) {
final ClearingOwnIdentity identity = new ClearingOwnIdentity();
identity.publicKeyPem = publicKeyPem;
identity.privateKeyPem = privateKeyPem;
identity.registeredWithNpci = false;
identity.createdOn = DateUtils.getOffsetDateTimeOfTenant();
return identity;
}
public void markRegisteredWithNpci() {
this.registeredWithNpci = true;
}
}
@@ -0,0 +1,28 @@
/**
* 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.clearing.npci.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClearingOwnIdentityRepository extends JpaRepository<ClearingOwnIdentity, Long> {
/** Singleton row — there is ever only one. */
Optional<ClearingOwnIdentity> findFirstByOrderByIdAsc();
}
@@ -0,0 +1,76 @@
/**
* 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.clearing.npci.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One row per package NPCI has pushed to this CBS's inward-callback endpoint — decrypted with this bank's
* own private key, NPCI's signature verified against NPCI's public key, and stored here for manual review.
* Deliberately does NOT auto-create an {@code InwardClearingBatch}/item — inward stays a manual, reviewed
* step for now; this is the raw record of what NPCI actually delivered.
*/
@Entity
@Table(name = "m_npci_inward_push_log")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class NpciInwardPushLog extends AbstractPersistableCustom<Long> {
@Column(name = "npci_package_id")
private Long npciPackageId;
@Column(name = "source_bank_code", length = 20)
private String sourceBankCode;
@Column(name = "destination_bank_code", length = 20)
private String destinationBankCode;
@Column(name = "source_batch_reference", length = 100)
private String sourceBatchReference;
@Column(name = "signature_valid", nullable = false)
private boolean signatureValid;
@Column(name = "decrypted_package_json", columnDefinition = "TEXT")
private String decryptedPackageJson;
@Column(name = "received_on")
private OffsetDateTime receivedOn;
public static NpciInwardPushLog create(final Long npciPackageId, final String sourceBankCode, final String destinationBankCode,
final String sourceBatchReference, final boolean signatureValid, final String decryptedPackageJson) {
final NpciInwardPushLog log = new NpciInwardPushLog();
log.npciPackageId = npciPackageId;
log.sourceBankCode = sourceBankCode;
log.destinationBankCode = destinationBankCode;
log.sourceBatchReference = sourceBatchReference;
log.signatureValid = signatureValid;
log.decryptedPackageJson = decryptedPackageJson;
log.receivedOn = DateUtils.getOffsetDateTimeOfTenant();
return log;
}
}
@@ -0,0 +1,24 @@
/**
* 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.clearing.npci.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NpciInwardPushLogRepository extends JpaRepository<NpciInwardPushLog, Long> {
}
@@ -0,0 +1,286 @@
/**
* 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.clearing.npci.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.security.PublicKey;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.chequebook.domain.ChequeBook;
import org.apache.fineract.portfolio.chequebook.domain.ChequeLeaf;
import org.apache.fineract.portfolio.chequebook.domain.ChequeLeafRepository;
import org.apache.fineract.portfolio.clearing.npci.client.NpciClearingClient;
import org.apache.fineract.portfolio.clearing.npci.crypto.NpciCryptoUtil;
import org.apache.fineract.portfolio.clearing.npci.data.NpciInwardPushRequest;
import org.apache.fineract.portfolio.clearing.npci.domain.NpciInwardPushLog;
import org.apache.fineract.portfolio.clearing.npci.domain.NpciInwardPushLogRepository;
import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingBatch;
import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingBatchRepository;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Handles a package NPCI pushes to this CBS's inward-callback endpoint: decrypts it with this bank's own
* private key, verifies NPCI's signature against NPCI's public key, records the result in
* {@link NpciInwardPushLog} for audit, and — when the signature is valid — auto-creates a real
* {@link InwardClearingBatch}/{@link InwardClearingItem} set (the same entities the manual "Receive Batch"
* screen creates), so it shows up on the existing Inward screen and flows through the existing
* validate/decide UI unchanged. An unsigned/invalid package is logged but never turned into clearing data.
*/
@Service
public class NpciInwardPushService {
private static final Logger log = LoggerFactory.getLogger(NpciInwardPushService.class);
private final NpciCryptoUtil cryptoUtil;
private final NpciClearingClient npciClearingClient;
private final NpciInwardPushLogRepository pushLogRepository;
private final InwardClearingBatchRepository batchRepository;
private final InwardClearingBatchRepositoryWrapper batchRepositoryWrapper;
private final InwardClearingItemRepository itemRepository;
private final ChequeLeafRepository chequeLeafRepository;
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) {
this.cryptoUtil = cryptoUtil;
this.npciClearingClient = npciClearingClient;
this.pushLogRepository = pushLogRepository;
this.batchRepository = batchRepository;
this.batchRepositoryWrapper = batchRepositoryWrapper;
this.itemRepository = itemRepository;
this.chequeLeafRepository = chequeLeafRepository;
this.contentStoreService = contentStoreService;
}
@Transactional
public void receivePush(final NpciInwardPushRequest request) {
log.info("Received NPCI inward push: packageId={} sourceBank={} batchRef={}", request.getId(), request.getSourceBankCode(),
request.getSourceBatchReference());
final String decrypted = this.cryptoUtil.decryptOwn(request.getEnvelope());
final PublicKey npciPublicKey = this.npciClearingClient.fetchNpciPublicKey();
final boolean signatureValid = this.cryptoUtil.verify(request.getEnvelope(), npciPublicKey);
if (!signatureValid) {
log.warn("NPCI inward push packageId={} FAILED signature verification — stored anyway, flagged for review",
request.getId());
}
final NpciInwardPushLog savedLog = this.pushLogRepository.save(NpciInwardPushLog.create(request.getId(),
request.getSourceBankCode(), request.getDestinationBankCode(), request.getSourceBatchReference(), signatureValid,
decrypted));
log.info("Stored NPCI inward push as log id={} (signatureValid={})", savedLog.getId(), signatureValid);
if (!signatureValid) {
log.warn("Skipping auto-batch creation for packageId={} — signature invalid", request.getId());
return;
}
final String batchReference = "NPCI-PKG-" + request.getId();
if (this.batchRepository.existsByBatchReferenceIgnoreCase(batchReference)) {
log.info("Inward batch {} already exists (re-push) — skipping duplicate creation", batchReference);
return;
}
try {
createInwardBatch(batchReference, decrypted, request);
} catch (final Exception e) {
log.error("Failed to auto-create inward batch from NPCI push packageId={}: {}", request.getId(), e.toString(), e);
}
}
private void createInwardBatch(final String batchReference, final String decrypted, final NpciInwardPushRequest request) {
final JsonObject root = JsonParser.parseString(decrypted).getAsJsonObject();
final JsonObject header = root.has("inwardPackageHeader") ? root.getAsJsonObject("inwardPackageHeader") : null;
final String originalBatchReference = header != null && header.has("originalBatchReference")
? header.get("originalBatchReference").getAsString()
: request.getSourceBatchReference();
final String sourceBankCode = header != null && header.has("sourceBankCode") ? header.get("sourceBankCode").getAsString()
: request.getSourceBankCode();
final JsonArray instruments = root.has("instruments") ? root.getAsJsonArray("instruments") : new JsonArray();
final LocalDate sessionDate = DateUtils.getBusinessLocalDate();
final InwardClearingBatch batch = InwardClearingBatch.receive(batchReference, sessionDate, "MICR",
"NPCI push — original batch " + originalBatchReference, sourceBankCode, null, null, null);
final InwardClearingBatch savedBatch = this.batchRepositoryWrapper.saveAndFlush(batch);
final List<InwardClearingItem> items = new ArrayList<>();
BigDecimal totalAmount = BigDecimal.ZERO;
for (final JsonElement el : instruments) {
final InwardClearingItem item = buildItem(savedBatch, el.getAsJsonObject());
items.add(item);
totalAmount = totalAmount.add(item.getAmount() == null ? BigDecimal.ZERO : item.getAmount());
}
this.itemRepository.saveAll(items);
// flush explicitly — saveAll() alone does not guarantee IDENTITY-generated ids are populated yet, and
// attachImages() below needs a real item id to build each image's storage path.
this.itemRepository.flush();
// images are attached after the first save+flush so each item has a real id for the storage path
for (int i = 0; i < items.size(); i++) {
attachImages(items.get(i), instruments.get(i).getAsJsonObject());
}
this.itemRepository.saveAll(items);
savedBatch.setTotals(items.size(), totalAmount);
this.batchRepositoryWrapper.saveAndFlush(savedBatch);
log.info("Auto-created inward clearing batch {} ({} item(s), total {}) from NPCI push packageId={}", savedBatch.getId(),
items.size(), totalAmount, request.getId());
}
private InwardClearingItem buildItem(final InwardClearingBatch batch, final JsonObject instrument) {
final String chequeNumber = getString(instrument, "instrumentNumber");
final String micrCode = getString(instrument, "micrCode");
final String ifsc = getString(instrument, "draweeBranchIfsc");
final String payeeName = getString(instrument, "payeeName");
final BigDecimal amount = instrument.has("amount") && !instrument.get("amount").isJsonNull()
? instrument.get("amount").getAsBigDecimal()
: BigDecimal.ZERO;
final String amountInWords = getString(instrument, "amountInWords");
final LocalDate instrumentDate = parseDate(getString(instrument, "instrumentDate"));
final LocalDate presentedDate = DateUtils.getBusinessLocalDate();
final ResolvedAccount resolved = resolveAccount(chequeNumber);
final InwardClearingItem item = InwardClearingItem.receive(batch, resolved.chequeLeafId, resolved.accountType,
resolved.accountId, chequeNumber, micrCode, ifsc, payeeName, amount, amountInWords, instrumentDate, presentedDate);
final JsonObject images = instrument.has("images") ? instrument.getAsJsonObject("images") : null;
final JsonObject frontImage = images != null && images.has("frontImage") ? images.getAsJsonObject("frontImage") : null;
final JsonObject backImage = images != null && images.has("backImage") ? images.getAsJsonObject("backImage") : null;
item.captureImages(getString(frontImage, "imageRef"), getString(backImage, "imageRef"), getString(instrument, "iqaStatus"),
getString(instrument, "iqaDetail"));
// presentingBranchCode is the DEPOSITING bank's own branch (from the instrument's own depositBranchCode),
// not the sender bank's code — the batch already carries sourceBankCode separately via presentingBankCode.
final String presentingBranchCode = getString(instrument, "depositBranchCode");
item.recordInwardMeta(getString(instrument, "serialNumber"), presentingBranchCode, batch.getReturnTatDeadline());
final Boolean instrumentStopPaymentFlag = instrument.has("stopPaymentFlag") && !instrument.get("stopPaymentFlag").isJsonNull()
? instrument.get("stopPaymentFlag").getAsBoolean()
: null;
item.recordSourceDetails(getString(instrument, "ctsReferenceNumber"), instrumentStopPaymentFlag,
parseDate(getString(instrument, "depositDate")));
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) {
final ResolvedAccount resolved = new ResolvedAccount();
if (chequeNumber == null) {
return resolved;
}
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();
}
}
} 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;
}
/** 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")) {
return;
}
final JsonObject images = instrument.getAsJsonObject("images");
attachImageSide(item, "front", images.has("frontImage") ? images.getAsJsonObject("frontImage") : null);
attachImageSide(item, "back", images.has("backImage") ? images.getAsJsonObject("backImage") : null);
}
private void attachImageSide(final InwardClearingItem item, final String side, final JsonObject imageBlock) {
if (imageBlock == null || !"base64".equalsIgnoreCase(getString(imageBlock, "encoding")) || !imageBlock.has("data")) {
return;
}
try {
final byte[] bytes = Base64.getDecoder().decode(imageBlock.get("data").getAsString());
String mimeType = getString(imageBlock, "format");
if (mimeType == null || mimeType.isBlank()) {
mimeType = "image/png";
}
final String storagePath = "images" + this.contentStoreService.getDelimiter() + "ctsclearing-inward"
+ this.contentStoreService.getDelimiter() + item.getId() + this.contentStoreService.getDelimiter() + side
+ extensionForMime(mimeType);
final String savedPath = this.contentStoreService.upload(storagePath, new ByteArrayInputStream(bytes), mimeType);
item.recordImageStore(side, mimeType + "|" + savedPath);
} catch (final Exception e) {
log.warn("Failed to store {} image for auto-created inward item {} from NPCI push: {}", side, item.getId(), e.toString());
}
}
private static String extensionForMime(final String mimeType) {
final String lower = mimeType.toLowerCase();
if (lower.contains("jpeg") || lower.contains("jpg")) {
return ".jpg";
}
if (lower.contains("gif")) {
return ".gif";
}
return ".png";
}
private static String getString(final JsonObject obj, final String key) {
return obj != null && obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null;
}
private static LocalDate parseDate(final String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return LocalDate.parse(value);
} catch (final RuntimeException e) {
return null;
}
}
private static final class ResolvedAccount {
private Long chequeLeafId;
private String accountType;
private Long accountId;
}
}
@@ -0,0 +1,112 @@
/**
* 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.clearing.serialization;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.clearing.api.ClearingApiConstants;
import org.apache.fineract.portfolio.clearing.api.ClearingApiJsonInputParams;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public final class ClearingCommandFromApiJsonDeserializer {
private final FromJsonHelper fromApiJsonHelper;
public void validateForCreateSettlementBatch(final String json) {
final JsonElement element = parse(json, ClearingApiJsonInputParams.settlementBatchCreateParams());
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClearingApiConstants.RESOURCE_SETTLEMENT_BATCH);
final String clearingType = this.fromApiJsonHelper.extractStringNamed(ClearingApiJsonInputParams.CLEARING_TYPE.getValue(), element);
// NB: isOneOfTheseStringValues lowercases the input before comparing, so the expected set must be lowercase
v.reset().parameter(ClearingApiJsonInputParams.CLEARING_TYPE.getValue()).value(clearingType).notBlank()
.isOneOfTheseStringValues("cts", "nach");
final Long sourceBatchId = this.fromApiJsonHelper.extractLongNamed(ClearingApiJsonInputParams.SOURCE_BATCH_ID.getValue(), element);
v.reset().parameter(ClearingApiJsonInputParams.SOURCE_BATCH_ID.getValue()).value(sourceBatchId).notNull().longGreaterThanZero();
throwIfErrors(errors);
}
public void validateForReconciliation(final String json) {
final JsonElement element = parse(json, ClearingApiJsonInputParams.reconciliationParams());
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClearingApiConstants.RESOURCE_RECONCILIATION);
final String clearingType = this.fromApiJsonHelper.extractStringNamed(ClearingApiJsonInputParams.CLEARING_TYPE.getValue(), element);
v.reset().parameter(ClearingApiJsonInputParams.CLEARING_TYPE.getValue()).value(clearingType).notBlank()
.isOneOfTheseStringValues("cts", "nach");
final LocalDate reconciliationDate = this.fromApiJsonHelper
.extractLocalDateNamed(ClearingApiJsonInputParams.RECONCILIATION_DATE.getValue(), element);
v.reset().parameter(ClearingApiJsonInputParams.RECONCILIATION_DATE.getValue()).value(reconciliationDate).notNull();
final BigDecimal cbsTotal = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(ClearingApiJsonInputParams.CBS_TOTAL.getValue(),
element);
v.reset().parameter(ClearingApiJsonInputParams.CBS_TOTAL.getValue()).value(cbsTotal).notNull();
final BigDecimal clearingHouseTotal = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(ClearingApiJsonInputParams.CLEARING_HOUSE_TOTAL.getValue(), element);
v.reset().parameter(ClearingApiJsonInputParams.CLEARING_HOUSE_TOTAL.getValue()).value(clearingHouseTotal).notNull();
throwIfErrors(errors);
}
public void validateForRejectSettlement(final String json) {
final JsonElement element = parse(json, ClearingApiJsonInputParams.rejectSettlementParams());
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClearingApiConstants.RESOURCE_SETTLEMENT_BATCH);
final String reason = this.fromApiJsonHelper.extractStringNamed(ClearingApiJsonInputParams.REASON.getValue(), element);
v.reset().parameter(ClearingApiJsonInputParams.REASON.getValue()).value(reason).notBlank().notExceedingLengthOf(500);
throwIfErrors(errors);
}
private JsonElement parse(final String json, final Set<String> supportedParams) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParams);
return this.fromApiJsonHelper.parse(json);
}
private void throwIfErrors(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
}
@@ -0,0 +1,72 @@
/**
* 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.clearing.service;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Single home for the "which GL account backs this thing" lookups the clearing modules need. Extracts the
* control-GL query that was previously copy-pasted in {@code ChequeBookIssuanceChargeService} and
* {@code ClerkDisbursementRequestApiResource} into one shared bean (a follow-up change can have those two
* delegate here so there is exactly one copy), and adds the clearing-suspense/settlement GL lookup keyed off
* {@code m_clearing_gl_config}.
*/
@Service
@RequiredArgsConstructor
public class ClearingGlAccountResolverService {
private final JdbcTemplate jdbcTemplate;
/** The customer account's control GL (SAVINGS or CURRENT), or null when no product mapping exists. */
public Long resolveControlGlAccountId(final String accountType, final Long accountId) {
if (accountId == null || accountType == null) {
return null;
}
if ("SAVINGS".equalsIgnoreCase(accountType)) {
return querySingleLong("SELECT apm.gl_account_id FROM m_savings_account sa "
+ "JOIN acc_product_mapping apm ON apm.product_id = sa.product_id "
+ "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId);
}
if ("CURRENT".equalsIgnoreCase(accountType)) {
return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId);
}
return null;
}
/** The GL account configured for a clearing role (e.g. {@code CTS_INWARD_SUSPENSE}), or null if unmapped. */
public Long resolveClearingGlAccountId(final String configKey) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM m_clearing_gl_config WHERE config_key = ? LIMIT 1", configKey);
if (rows.isEmpty() || rows.get(0).get("gl_account_id") == null) {
return null;
}
return ((Number) rows.get(0).get("gl_account_id")).longValue();
}
private Long querySingleLong(final String sql, final Object... args) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, args);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
}
@@ -0,0 +1,110 @@
/**
* 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.clearing.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Posts manual journal entries for the clearing modules through Fineract's standard {@code createJournalEntry}
* command pipeline — the same mechanism {@code ChequeBookIssuanceChargeService.postChargeJournalEntry} uses.
* Centralised here so CTS per-item debits, NACH postings, and settlement summaries all share one balanced,
* audited posting path (and one place to link a GL row back to a customer account for reporting).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ClearingJournalService {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final JdbcTemplate jdbcTemplate;
/**
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
* on failure). {@code linkAccountId} is optional — when supplied, the debit row is linked to that customer
* account so it shows an "Account No" in the Journal Entries screen.
*/
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
creditGlId, amount);
return null;
}
final JsonArray debitArr = new JsonArray();
final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", debitGlId);
debit.addProperty("amount", amount);
debitArr.add(debit);
final JsonArray creditArr = new JsonArray();
final JsonObject credit = new JsonObject();
credit.addProperty("glAccountId", creditGlId);
credit.addProperty("amount", amount);
creditArr.add(credit);
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", officeId);
payload.addProperty("transactionDate", txnDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")));
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "INR");
payload.addProperty("comments", comments);
payload.add("debits", debitArr);
payload.add("credits", creditArr);
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
if (result == null) {
log.warn("Clearing journal entry did not return a result ({}).", comments);
return null;
}
final String glTxnId = result.getTransactionId();
if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId);
}
return glTxnId;
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
rowIds.get(0));
}
}
}
@@ -0,0 +1,45 @@
/**
* 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.clearing.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.clearing.domain.ClearingNotification;
import org.apache.fineract.portfolio.clearing.domain.ClearingNotificationRepository;
import org.springframework.stereotype.Service;
/**
* Queues simulated customer notifications for clearing events. A single write path used by both CTS and NACH
* decisioning; if a real SMS/email gateway is ever wired in, this is the one place to change.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ClearingNotificationService {
private final ClearingNotificationRepository repository;
public void notifyCustomer(final String channel, final String recipient, final String event, final String clearingType,
final String refType, final Long refId, final String message) {
final ClearingNotification notification = ClearingNotification.simulated(channel, recipient, event, clearingType, refType, refId,
message);
this.repository.save(notification);
log.info("[CLEARING-NOTIFY][{}] {} -> {} : {}", clearingType, event, recipient, message);
}
}
@@ -0,0 +1,38 @@
/**
* 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.clearing.service;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.clearing.data.ClearingReconciliationData;
import org.apache.fineract.portfolio.clearing.data.ClearingReturnReasonCodeData;
import org.apache.fineract.portfolio.clearing.data.ClearingSettlementBatchData;
public interface ClearingReadPlatformService {
List<ClearingReturnReasonCodeData> retrieveReturnReasonCodes(String appliesTo);
Page<ClearingSettlementBatchData> retrieveSettlementBatches(String clearingType, String status, Integer offset, Integer limit);
ClearingSettlementBatchData retrieveSettlementBatch(Long id);
Page<ClearingReconciliationData> retrieveReconciliations(String clearingType, Integer offset, Integer limit);
ClearingReconciliationData retrieveReconciliation(Long id);
}
@@ -0,0 +1,192 @@
/**
* 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.clearing.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.clearing.data.ClearingReconciliationData;
import org.apache.fineract.portfolio.clearing.data.ClearingReturnReasonCodeData;
import org.apache.fineract.portfolio.clearing.data.ClearingSettlementBatchData;
import org.apache.fineract.portfolio.clearing.exception.ClearingReconciliationNotFoundException;
import org.apache.fineract.portfolio.clearing.exception.ClearingSettlementBatchNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ClearingReadPlatformServiceImpl implements ClearingReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final ReturnReasonCodeMapper returnReasonCodeMapper = new ReturnReasonCodeMapper();
private final SettlementBatchMapper settlementBatchMapper = new SettlementBatchMapper();
private final ReconciliationMapper reconciliationMapper = new ReconciliationMapper();
@Override
public List<ClearingReturnReasonCodeData> retrieveReturnReasonCodes(final String appliesTo) {
final StringBuilder where = new StringBuilder(" where is_active = true");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(appliesTo)) {
where.append(" and (applies_to = ? or applies_to = 'BOTH')");
args.add(appliesTo.trim().toUpperCase());
}
final String sql = "select " + this.returnReasonCodeMapper.schema() + where + " order by code asc";
return this.jdbcTemplate.query(sql, this.returnReasonCodeMapper, args.toArray());
}
@Override
public Page<ClearingSettlementBatchData> retrieveSettlementBatches(final String clearingType, final String status,
final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" where 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(clearingType)) {
where.append(" and clearing_type = ?");
args.add(clearingType.trim().toUpperCase());
}
if (StringUtils.isNotBlank(status)) {
where.append(" and status = ?");
args.add(status.trim().toUpperCase());
}
final String countSql = "select count(*) from (select " + this.settlementBatchMapper.schema() + where + ") x";
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final StringBuilder pageSql = new StringBuilder("select ").append(this.settlementBatchMapper.schema()).append(where)
.append(" order by id desc limit ? offset ?");
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<ClearingSettlementBatchData> batches = this.jdbcTemplate.query(pageSql.toString(), this.settlementBatchMapper,
pageArgs.toArray());
return new Page<>(batches, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
@Override
public ClearingSettlementBatchData retrieveSettlementBatch(final Long id) {
try {
final String sql = "select " + this.settlementBatchMapper.schema() + " where id = ?";
return this.jdbcTemplate.queryForObject(sql, this.settlementBatchMapper, id);
} catch (final EmptyResultDataAccessException e) {
throw new ClearingSettlementBatchNotFoundException(id);
}
}
@Override
public Page<ClearingReconciliationData> retrieveReconciliations(final String clearingType, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" where 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(clearingType)) {
where.append(" and clearing_type = ?");
args.add(clearingType.trim().toUpperCase());
}
final String countSql = "select count(*) from (select " + this.reconciliationMapper.schema() + where + ") x";
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final StringBuilder pageSql = new StringBuilder("select ").append(this.reconciliationMapper.schema()).append(where)
.append(" order by id desc limit ? offset ?");
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<ClearingReconciliationData> reconciliations = this.jdbcTemplate.query(pageSql.toString(), this.reconciliationMapper,
pageArgs.toArray());
return new Page<>(reconciliations, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
@Override
public ClearingReconciliationData retrieveReconciliation(final Long id) {
try {
final String sql = "select " + this.reconciliationMapper.schema() + " where id = ?";
return this.jdbcTemplate.queryForObject(sql, this.reconciliationMapper, id);
} catch (final EmptyResultDataAccessException e) {
throw new ClearingReconciliationNotFoundException(id);
}
}
private static final class ReturnReasonCodeMapper implements RowMapper<ClearingReturnReasonCodeData> {
String schema() {
return " id as id, code as code, description as description, category as category, applies_to as applies_to, "
+ "is_active as is_active from m_clearing_return_reason_code";
}
@Override
public ClearingReturnReasonCodeData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return ClearingReturnReasonCodeData.builder().id(rs.getLong("id")).code(rs.getString("code"))
.description(rs.getString("description")).category(rs.getString("category")).appliesTo(rs.getString("applies_to"))
.active(rs.getBoolean("is_active")).build();
}
}
private static final class SettlementBatchMapper implements RowMapper<ClearingSettlementBatchData> {
String schema() {
return " id as id, clearing_type as clearing_type, direction as direction, settlement_date as settlement_date, "
+ "source_reference as source_reference, source_batch_id as source_batch_id, "
+ "total_passed_amount as total_passed_amount, total_returned_amount as total_returned_amount, "
+ "net_amount as net_amount, passed_count as passed_count, returned_count as returned_count, "
+ "office_id as office_id, currency_code as currency_code, gl_transaction_id as gl_transaction_id, "
+ "status as status, prepared_by as prepared_by, prepared_on as prepared_on, posted_by as posted_by, "
+ "posted_on as posted_on, rejection_reason as rejection_reason from m_clearing_settlement_batch";
}
@Override
public ClearingSettlementBatchData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return ClearingSettlementBatchData.builder().id(rs.getLong("id")).clearingType(rs.getString("clearing_type"))
.direction(rs.getString("direction")).settlementDate(rs.getObject("settlement_date", LocalDate.class))
.sourceReference(rs.getString("source_reference")).sourceBatchId((Long) rs.getObject("source_batch_id"))
.totalPassedAmount(rs.getBigDecimal("total_passed_amount"))
.totalReturnedAmount(rs.getBigDecimal("total_returned_amount")).netAmount(rs.getBigDecimal("net_amount"))
.passedCount((Integer) rs.getObject("passed_count")).returnedCount((Integer) rs.getObject("returned_count"))
.officeId((Long) rs.getObject("office_id")).currencyCode(rs.getString("currency_code"))
.glTransactionId(rs.getString("gl_transaction_id")).status(rs.getString("status"))
.preparedBy((Long) rs.getObject("prepared_by")).preparedOn(rs.getObject("prepared_on", OffsetDateTime.class))
.postedBy((Long) rs.getObject("posted_by")).postedOn(rs.getObject("posted_on", OffsetDateTime.class))
.rejectionReason(rs.getString("rejection_reason")).build();
}
}
private static final class ReconciliationMapper implements RowMapper<ClearingReconciliationData> {
String schema() {
return " id as id, clearing_type as clearing_type, reconciliation_date as reconciliation_date, "
+ "source_reference as source_reference, cbs_total as cbs_total, clearing_house_total as clearing_house_total, "
+ "difference as difference, status as status, detail as detail, run_by as run_by, created_on as created_on "
+ "from m_clearing_reconciliation";
}
@Override
public ClearingReconciliationData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return ClearingReconciliationData.builder().id(rs.getLong("id")).clearingType(rs.getString("clearing_type"))
.reconciliationDate(rs.getObject("reconciliation_date", LocalDate.class))
.sourceReference(rs.getString("source_reference")).cbsTotal(rs.getBigDecimal("cbs_total"))
.clearingHouseTotal(rs.getBigDecimal("clearing_house_total")).difference(rs.getBigDecimal("difference"))
.status(rs.getString("status")).detail(rs.getString("detail")).runBy((Long) rs.getObject("run_by"))
.createdOn(rs.getObject("created_on", OffsetDateTime.class)).build();
}
}
}
@@ -0,0 +1,45 @@
/**
* 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.clearing.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.clearing.domain.ClearingReconciliation;
import org.apache.fineract.portfolio.clearing.domain.ClearingReconciliationRepository;
import org.springframework.stereotype.Service;
/**
* Records a reconciliation run comparing the CBS's own settled total for a session against the clearing house's
* reported figure (spec sections 4.11 &amp; 8.9). The clearing-house total is operator-supplied in this
* simulation. Any non-zero difference is flagged as a MISMATCH for follow-up.
*/
@Service
@RequiredArgsConstructor
public class ClearingReconciliationService {
private final ClearingReconciliationRepository repository;
public ClearingReconciliation reconcile(final String clearingType, final LocalDate reconciliationDate, final String sourceReference,
final BigDecimal cbsTotal, final BigDecimal clearingHouseTotal, final String detail, final Long runBy) {
final ClearingReconciliation reconciliation = ClearingReconciliation.run(clearingType, reconciliationDate, sourceReference,
cbsTotal, clearingHouseTotal, detail, runBy);
return this.repository.save(reconciliation);
}
}
@@ -0,0 +1,107 @@
/**
* 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.clearing.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.clearing.domain.ClearingSettlementBatch;
import org.apache.fineract.portfolio.clearing.domain.ClearingSettlementBatchRepositoryWrapper;
import org.springframework.stereotype.Service;
/**
* Net-settlement engine shared by CTS and NACH (spec sections 4.9 &amp; 8.8). {@link #prepare} computes a
* PENDING settlement batch from a decisioned session's totals; {@link #post} authorises it (a different user
* than the preparer) and writes the summary GL entry squaring off the clearing suspense account against the
* settlement (RTGS) account; {@link #reject} discards it.
*
* <p>
* Follows the "Model A" design: individual passed items already posted their own customer-debit/suspense-credit
* entries at decisioning time, so this summary entry moves the accumulated suspense balance to the settlement
* account — it does not re-debit customers. If the suspense/settlement GL accounts are not configured, the
* batch is still recorded (as a reconciliation control record) but no GL entry is posted, mirroring the
* graceful degradation of {@code ChequeBookIssuanceChargeService}.
* </p>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ClearingSettlementService {
private final ClearingSettlementBatchRepositoryWrapper settlementRepositoryWrapper;
private final ClearingGlAccountResolverService glResolver;
private final ClearingJournalService journalService;
private static final String SETTLEMENT_RTGS = "CLEARING_SETTLEMENT_RTGS";
public ClearingSettlementBatch prepare(final String clearingType, final String direction, final LocalDate settlementDate,
final String sourceReference, final Long sourceBatchId, final BigDecimal totalPassed, final BigDecimal totalReturned,
final Integer passedCount, final Integer returnedCount, final Long officeId, final String currencyCode,
final Long preparedBy) {
final ClearingSettlementBatch batch = ClearingSettlementBatch.prepare(clearingType, direction, settlementDate, sourceReference,
sourceBatchId, totalPassed, totalReturned, passedCount, returnedCount, officeId, currencyCode, preparedBy);
return this.settlementRepositoryWrapper.saveAndFlush(batch);
}
public ClearingSettlementBatch post(final Long settlementBatchId, final Long postedByUserId) {
final ClearingSettlementBatch batch = this.settlementRepositoryWrapper.findOneWithNotFoundDetection(settlementBatchId);
if (!batch.isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.clearing.settlement.not.pending",
"Settlement batch " + settlementBatchId + " is not PENDING.", settlementBatchId);
}
// Two-person control enforced in code, not just via can_maker_checker (ALL_FUNCTIONS bypasses the flag).
if (batch.isPreparedBy(postedByUserId)) {
throw new GeneralPlatformDomainRuleException("error.msg.clearing.settlement.self.post",
"The user who prepared settlement batch " + settlementBatchId + " cannot also post it.", settlementBatchId);
}
final String suspenseKey = suspenseKeyFor(batch.getClearingType());
final Long suspenseGl = this.glResolver.resolveClearingGlAccountId(suspenseKey);
final Long settlementGl = this.glResolver.resolveClearingGlAccountId(SETTLEMENT_RTGS);
String glTxnId = null;
final BigDecimal netPassed = batch.getTotalPassedAmount();
if (suspenseGl != null && settlementGl != null && netPassed != null && netPassed.signum() > 0) {
// DR clearing suspense (drawing down the accumulated passed-item credits), CR settlement/RTGS.
glTxnId = this.journalService.postJournalEntry(batch.getOfficeId(), batch.getCurrencyCode(), DateUtils.getBusinessLocalDate(),
suspenseGl, settlementGl, netPassed,
batch.getClearingType() + " net settlement - session " + batch.getSourceReference(), null);
} else {
log.warn("Settlement batch {} posted without a GL entry - suspense ({}) or settlement ({}) GL not configured, or zero net.",
settlementBatchId, suspenseGl, settlementGl);
}
batch.markPosted(postedByUserId, glTxnId);
return this.settlementRepositoryWrapper.saveAndFlush(batch);
}
public ClearingSettlementBatch reject(final Long settlementBatchId, final Long rejectedByUserId, final String reason) {
final ClearingSettlementBatch batch = this.settlementRepositoryWrapper.findOneWithNotFoundDetection(settlementBatchId);
batch.reject(rejectedByUserId, reason);
return this.settlementRepositoryWrapper.saveAndFlush(batch);
}
private String suspenseKeyFor(final String clearingType) {
if ("NACH".equalsIgnoreCase(clearingType)) {
return "NACH_SUSPENSE";
}
return "CTS_INWARD_SUSPENSE";
}
}
@@ -0,0 +1,33 @@
/**
* 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.clearing.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface ClearingWritePlatformService {
CommandProcessingResult prepareSettlement(JsonCommand command);
CommandProcessingResult postSettlement(Long settlementBatchId, JsonCommand command);
CommandProcessingResult rejectSettlement(Long settlementBatchId, JsonCommand command);
CommandProcessingResult createReconciliation(JsonCommand command);
}
@@ -0,0 +1,180 @@
/**
* 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.clearing.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.clearing.api.ClearingApiJsonInputParams;
import org.apache.fineract.portfolio.clearing.domain.ClearingReconciliation;
import org.apache.fineract.portfolio.clearing.domain.ClearingSettlementBatch;
import org.apache.fineract.portfolio.clearing.serialization.ClearingCommandFromApiJsonDeserializer;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
public class ClearingWritePlatformServiceImpl implements ClearingWritePlatformService {
private final PlatformSecurityContext context;
private final ClearingCommandFromApiJsonDeserializer validator;
private final ClearingSettlementService settlementService;
private final ClearingReconciliationService reconciliationService;
private final JdbcTemplate jdbcTemplate;
private static final String DEFAULT_CURRENCY = "INR";
// ============================= Settlement =============================
@Transactional
@Override
public CommandProcessingResult prepareSettlement(final JsonCommand command) {
this.validator.validateForCreateSettlementBatch(command.json());
final String clearingType = command.stringValueOfParameterNamed(ClearingApiJsonInputParams.CLEARING_TYPE.getValue())
.trim().toUpperCase();
final Long sourceBatchId = command.longValueOfParameterNamed(ClearingApiJsonInputParams.SOURCE_BATCH_ID.getValue());
final Long currentUserId = this.context.authenticatedUser().getId();
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final boolean nach = "NACH".equals(clearingType);
final String itemTable = nach ? "m_nach_transaction" : "m_inward_clearing_item";
final String batchTable = nach ? "m_nach_batch" : "m_inward_clearing_batch";
final SettlementTotals totals = computeTotals(itemTable, sourceBatchId);
final SourceBatchInfo batchInfo = loadSourceBatch(batchTable, sourceBatchId);
final String currency = batchInfo.currencyCode == null ? DEFAULT_CURRENCY : batchInfo.currencyCode;
final ClearingSettlementBatch batch = this.settlementService.prepare(clearingType, "INWARD", businessDate,
batchInfo.batchReference, sourceBatchId, totals.passedSum, totals.returnedSum, totals.passedCount, totals.returnedCount,
batchInfo.officeId, currency, currentUserId);
// tie every source item to this settlement batch, and mark the source batch SETTLED
this.jdbcTemplate.update("update " + itemTable + " set settlement_batch_id = ? where batch_id = ?", batch.getId(), sourceBatchId);
this.jdbcTemplate.update("update " + batchTable + " set status = 'SETTLED', settled_on = now() where id = ?", sourceBatchId);
log.info("Prepared {} settlement batch {} for source batch {} (passed {}, returned {})", clearingType, batch.getId(),
sourceBatchId, totals.passedSum, totals.returnedSum);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(batch.getId()).build();
}
@Transactional
@Override
public CommandProcessingResult postSettlement(final Long settlementBatchId, final JsonCommand command) {
final Long currentUserId = this.context.authenticatedUser().getId();
this.settlementService.post(settlementBatchId, currentUserId);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(settlementBatchId).build();
}
@Transactional
@Override
public CommandProcessingResult rejectSettlement(final Long settlementBatchId, final JsonCommand command) {
this.validator.validateForRejectSettlement(command.json());
final String reason = command.stringValueOfParameterNamed(ClearingApiJsonInputParams.REASON.getValue());
final Long currentUserId = this.context.authenticatedUser().getId();
this.settlementService.reject(settlementBatchId, currentUserId, reason);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(settlementBatchId).build();
}
// ============================= Reconciliation =============================
@Transactional
@Override
public CommandProcessingResult createReconciliation(final JsonCommand command) {
this.validator.validateForReconciliation(command.json());
final String clearingType = command.stringValueOfParameterNamed(ClearingApiJsonInputParams.CLEARING_TYPE.getValue())
.trim().toUpperCase();
final LocalDate reconciliationDate = command
.localDateValueOfParameterNamed(ClearingApiJsonInputParams.RECONCILIATION_DATE.getValue());
final String sourceReference = command
.stringValueOfParameterNamed(ClearingApiJsonInputParams.SOURCE_REFERENCE.getValue());
final BigDecimal cbsTotal = command.bigDecimalValueOfParameterNamed(ClearingApiJsonInputParams.CBS_TOTAL.getValue());
final BigDecimal clearingHouseTotal = command
.bigDecimalValueOfParameterNamed(ClearingApiJsonInputParams.CLEARING_HOUSE_TOTAL.getValue());
final String detail = command.stringValueOfParameterNamed(ClearingApiJsonInputParams.DETAIL.getValue());
final Long currentUserId = this.context.authenticatedUser().getId();
final ClearingReconciliation reconciliation = this.reconciliationService.reconcile(clearingType, reconciliationDate,
sourceReference, cbsTotal, clearingHouseTotal, detail, currentUserId);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(reconciliation.getId()).build();
}
// ============================= Helpers =============================
private SettlementTotals computeTotals(final String itemTable, final Long sourceBatchId) {
final SettlementTotals totals = new SettlementTotals();
// itemTable is a fixed, code-selected constant (never user input) — safe to interpolate
final String sql = "select decision, coalesce(sum(amount), 0) as amt, count(*) as cnt from " + itemTable
+ " where batch_id = ? group by decision";
this.jdbcTemplate.query(sql, rs -> {
final String decision = rs.getString("decision");
final BigDecimal amt = rs.getBigDecimal("amt");
final int cnt = rs.getInt("cnt");
if ("PASS".equalsIgnoreCase(decision)) {
totals.passedSum = amt;
totals.passedCount = cnt;
} else if ("RETURN".equalsIgnoreCase(decision)) {
totals.returnedSum = amt;
totals.returnedCount = cnt;
}
}, sourceBatchId);
return totals;
}
private SourceBatchInfo loadSourceBatch(final String batchTable, final Long sourceBatchId) {
try {
// neither source batch table carries a currency column — currency defaults to INR downstream
final String sql = "select office_id, batch_reference from " + batchTable + " where id = ?";
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
final SourceBatchInfo info = new SourceBatchInfo();
info.officeId = (Long) rs.getObject("office_id");
info.batchReference = rs.getString("batch_reference");
info.currencyCode = null;
return info;
}, sourceBatchId);
} catch (final EmptyResultDataAccessException e) {
throw new GeneralPlatformDomainRuleException("error.msg.clearing.settlement.source.batch.invalid",
"The source batch " + sourceBatchId + " for settlement does not exist.", sourceBatchId);
}
}
private static final class SettlementTotals {
private BigDecimal passedSum = BigDecimal.ZERO;
private BigDecimal returnedSum = BigDecimal.ZERO;
private int passedCount = 0;
private int returnedCount = 0;
}
private static final class SourceBatchInfo {
private Long officeId;
private String batchReference;
private String currencyCode;
}
}
@@ -0,0 +1,97 @@
/**
* 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.api;
public final class CtsClearingApiConstants {
private CtsClearingApiConstants() {}
// permission entity names (align with the CommandWrapperBuilder entity names)
public static final String ENTITY_INWARD_BATCH = "INWARDCLEARINGBATCH";
public static final String ENTITY_INWARD_ITEM = "INWARDCLEARINGITEM";
public static final String ENTITY_OUTWARD_PRESENTMENT = "OUTWARDCLEARINGPRESENTMENT";
public static final String ENTITY_POSITIVE_PAY = "POSITIVEPAYREGISTRATION";
// validation resource names
public static final String RESOURCE_INWARD_BATCH = "inwardclearingbatch";
public static final String RESOURCE_INWARD_ITEM = "inwardclearingitem";
public static final String RESOURCE_OUTWARD_PRESENTMENT = "outwardclearingpresentment";
// batch create params
public static final String BATCH_REFERENCE = "batchReference";
public static final String SESSION_DATE = "sessionDate";
public static final String PRESENTMENT_TYPE = "presentmentType";
public static final String SOURCE_FILE = "sourceFile";
public static final String PRESENTING_BANK_CODE = "presentingBankCode";
public static final String PRESENTING_BANK_NAME = "presentingBankName";
public static final String OFFICE_ID = "officeId";
public static final String ITEMS = "items";
// item fields (also used inside each element of items[])
public static final String CHEQUE_NUMBER = "chequeNumber";
public static final String MICR_CODE = "micrCode";
public static final String IFSC = "ifsc";
public static final String PAYEE_NAME = "payeeName";
public static final String AMOUNT = "amount";
public static final String AMOUNT_IN_WORDS = "amountInWords";
public static final String INSTRUMENT_DATE = "instrumentDate";
public static final String PRESENTED_DATE = "presentedDate";
public static final String ACCOUNT_TYPE = "accountType";
public static final String ACCOUNT_ID = "accountId";
public static final String ORIGINAL_SERIAL_NUMBER = "originalSerialNumber";
public static final String PRESENTING_BRANCH_CODE = "presentingBranchCode";
public static final String CTS_REFERENCE_NUMBER = "ctsReferenceNumber";
public static final String DEPOSIT_DATE = "depositDate";
// cheque image + IQA (image quality assessment) params, per item
public static final String FRONT_IMAGE_REF = "frontImageRef";
public static final String BACK_IMAGE_REF = "backImageRef";
public static final String IQA_STATUS = "iqaStatus";
public static final String IQA_DETAIL = "iqaDetail";
// item decision params
public static final String NOTE = "note";
public static final String RETURN_REASON_CODE = "returnReasonCode";
public static final String REASON = "reason";
// outward create params
public static final String DEPOSITING_ACCOUNT_TYPE = "depositingAccountType";
public static final String DEPOSITING_ACCOUNT_ID = "depositingAccountId";
public static final String DRAWER_BANK_MICR = "drawerBankMicr";
public static final String DRAWER_IFSC = "drawerIfsc";
public static final String DRAWER_BANK_NAME = "drawerBankName";
public static final String DEPOSITED_DATE = "depositedDate";
// outward capture params (stop-payment + image/IQA — captured at deposit time, not hardcoded)
public static final String STOP_PAYMENT_FLAG = "stopPaymentFlag";
public static final String DRAWER_ACCOUNT_NUMBER = "drawerAccountNumber";
public static final String GRID_ZONE_CODE = "gridZoneCode";
public static final String GRID_ZONE_NAME = "gridZoneName";
// outward action params
public static final String DISHONOUR_REASON_CODE = "dishonourReasonCode";
// positive pay params
public static final String RESOURCE_POSITIVE_PAY = "positivepayregistration";
public static final String CHANNEL = "channel";
// common
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
}
@@ -0,0 +1,41 @@
/**
* 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.api;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
/** Small helpers shared by the inward-item and outward-presentment image upload/serve endpoints. */
public final class CtsImageUtil {
private CtsImageUtil() {}
public static void validateSide(final String side) {
if (!"front".equalsIgnoreCase(side) && !"back".equalsIgnoreCase(side)) {
throw new UnrecognizedQueryParamException("side", side);
}
}
public static String extractExt(final String filename) {
if (filename == null) {
return ".png";
}
final int dot = filename.lastIndexOf('.');
return dot >= 0 ? filename.substring(dot).toLowerCase() : ".png";
}
}
@@ -0,0 +1,118 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.ctsclearing.data.InwardClearingBatchData;
import org.apache.fineract.portfolio.ctsclearing.data.InwardClearingItemData;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/ctsclearing/inward-batches")
@Component
@Tag(name = "CTS Inward Clearing Batches", description = "Received (simulated) inward clearing sessions of cheques drawn on this bank, "
+ "their validation and pass/return decisioning.")
@RequiredArgsConstructor
public class InwardClearingBatchApiResource {
private final PlatformSecurityContext context;
private final CtsClearingReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List inward clearing batches")
public Page<InwardClearingBatchData> retrieveAll(@QueryParam("status") final String status,
@QueryParam("fromDate") @Parameter(description = "Session date lower bound (yyyy-MM-dd)") final String fromDate,
@QueryParam("toDate") @Parameter(description = "Session date upper bound (yyyy-MM-dd)") final String toDate,
@QueryParam("batchReference") final String batchReference,
@QueryParam("presentingBankName") final String presentingBankName,
@QueryParam("offset") @DefaultValue("0") final Integer offset, @QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_INWARD_BATCH);
final int safeLimit = Math.min(limit, 200);
return this.readPlatformService.retrieveAllBatches(status, fromDate != null ? LocalDate.parse(fromDate) : null,
toDate != null ? LocalDate.parse(toDate) : null, batchReference, presentingBankName, offset, safeLimit);
}
@GET
@Path("{id}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an inward clearing batch")
public InwardClearingBatchData retrieveOne(@PathParam("id") @Parameter(description = "id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_INWARD_BATCH);
return this.readPlatformService.retrieveBatch(id);
}
@GET
@Path("{id}/items")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List the items of an inward clearing batch")
public List<InwardClearingItemData> retrieveItems(@PathParam("id") @Parameter(description = "id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_INWARD_BATCH);
return this.readPlatformService.retrieveItemsByBatch(id);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Receive an inward clearing batch")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInwardClearingBatch().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a command on an inward clearing batch", description = "Supported commands: validate.")
public CommandProcessingResult handleCommand(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("command") @Parameter(description = "validate") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
final CommandWrapper request;
if ("validate".equalsIgnoreCase(commandParam)) {
request = builder.validateInwardClearingBatch(id).build();
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,145 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
import java.util.Base64;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.ctsclearing.data.InwardClearingItemData;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingReadPlatformService;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingWritePlatformService;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
@Path("/v1/ctsclearing/inward-items")
@Component
@Tag(name = "CTS Inward Clearing Items", description = "Individual cheques within an inward clearing batch and their pass/return decision.")
@RequiredArgsConstructor
public class InwardClearingItemApiResource {
private final PlatformSecurityContext context;
private final CtsClearingReadPlatformService readPlatformService;
private final CtsClearingWritePlatformService writePlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final ContentStoreService contentStoreService;
@GET
@Path("{id}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an inward clearing item")
public InwardClearingItemData retrieveOne(@PathParam("id") @Parameter(description = "id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_INWARD_ITEM);
return this.readPlatformService.retrieveItem(id);
}
@POST
@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.")
public CommandProcessingResult handleCommand(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("command") @Parameter(description = "pass | return") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
final CommandWrapper request;
if ("pass".equalsIgnoreCase(commandParam)) {
request = builder.passInwardClearingItem(id).build();
} else if ("return".equalsIgnoreCase(commandParam)) {
request = builder.returnInwardClearingItem(id).build();
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
/**
* Uploads a real scanned cheque image (front or back) for this item. Stored via {@code ContentStoreService}
* (the same filesystem/S3 abstraction already used elsewhere in this fork, e.g. election candidate
* symbols) — only the resulting "mimeType|storagePath" is kept on the item, never the raw bytes in the DB.
*/
@POST
@Path("{id}/image")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Upload the front or back cheque image for an inward item")
public Response uploadImage(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("side") @Parameter(description = "front | back") final String side,
@FormDataParam("file") final InputStream fileStream, @FormDataParam("file") final FormDataContentDisposition fileDetail,
@FormDataParam("file") final FormDataBodyPart filePart) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_INWARD_ITEM);
CtsImageUtil.validateSide(side);
final String mimeType = filePart != null && filePart.getMediaType() != null ? filePart.getMediaType().toString() : "image/png";
final String ext = CtsImageUtil.extractExt(fileDetail != null ? fileDetail.getFileName() : null);
final String storagePath = "images" + this.contentStoreService.getDelimiter() + "ctsclearing-inward"
+ this.contentStoreService.getDelimiter() + id + this.contentStoreService.getDelimiter() + side.toLowerCase() + ext;
final String savedPath = this.contentStoreService.upload(storagePath, fileStream, mimeType);
this.writePlatformService.recordInwardItemImage(id, side, mimeType + "|" + savedPath);
return Response.ok(Map.of("resourceId", id)).build();
}
/**
* Returns the front/back cheque image as a base64 data URL ({@code data:<mimeType>;base64,<bytes>}), ready
* to bind directly to an <code>&lt;img&gt;</code> tag once fetched through an authenticated HTTP call —
* NOT meant to be used as a raw <code>&lt;img src&gt;</code> URL (the API requires auth headers a plain
* image tag can't send).
*/
@GET
@Path("{id}/image")
@Produces(MediaType.TEXT_PLAIN)
@Operation(summary = "Retrieve the front or back cheque image for an inward item as a base64 data URL")
public Response getImage(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("side") @Parameter(description = "front | back") final String side) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_INWARD_ITEM);
CtsImageUtil.validateSide(side);
final String store = this.readPlatformService.retrieveInwardItemImageStore(id, side);
if (store == null || store.isBlank() || !store.contains("|")) {
return Response.status(Response.Status.NOT_FOUND).build();
}
final String[] parts = store.split("\\|", 2);
try {
final byte[] bytes = this.contentStoreService.download(parts[1]).readAllBytes();
final String dataUrl = "data:" + parts[0] + ";base64," + Base64.getEncoder().encodeToString(bytes);
return Response.ok(dataUrl).build();
} catch (final Exception e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
}
@@ -0,0 +1,206 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Base64;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.ctsclearing.data.OutwardClearingBatchSummaryData;
import org.apache.fineract.portfolio.ctsclearing.data.OutwardClearingPresentmentData;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingReadPlatformService;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingWritePlatformService;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
@Path("/v1/ctsclearing/outward-presentments")
@Component
@Tag(name = "CTS Outward Clearing Presentments", description = "Cheques our customers deposited, drawn on other banks, captured for onward "
+ "presentation to the clearing house.")
@RequiredArgsConstructor
public class OutwardClearingPresentmentApiResource {
private final PlatformSecurityContext context;
private final CtsClearingReadPlatformService readPlatformService;
private final CtsClearingWritePlatformService writePlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final ContentStoreService contentStoreService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List outward clearing presentments")
public Page<OutwardClearingPresentmentData> retrieveAll(@QueryParam("status") final String status,
@QueryParam("fromDate") @Parameter(description = "Deposited-date lower bound (yyyy-MM-dd)") final String fromDate,
@QueryParam("toDate") @Parameter(description = "Deposited-date upper bound (yyyy-MM-dd)") final String toDate,
@QueryParam("chequeNumber") final String chequeNumber, @QueryParam("drawerBankName") final String drawerBankName,
@QueryParam("payeeName") final String payeeName, @QueryParam("batchReference") final String batchReference,
@QueryParam("offset") @DefaultValue("0") final Integer offset, @QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_OUTWARD_PRESENTMENT);
final int safeLimit = Math.min(limit, 200);
return this.readPlatformService.retrieveAllOutward(status, fromDate != null ? LocalDate.parse(fromDate) : null,
toDate != null ? LocalDate.parse(toDate) : null, chequeNumber, drawerBankName, payeeName, batchReference, offset,
safeLimit);
}
/**
* A "batch" is every outward presentment that shares one batchReference — the real clearing-session
* grouping produced either by the 2-hourly scheduled job or the manual "Send Pending Batch" fallback.
*/
@GET
@Path("batches")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List outward clearing sessions (batches)")
public Page<OutwardClearingBatchSummaryData> retrieveBatches(
@QueryParam("fromDate") @Parameter(description = "Sessions sent on/after this date (yyyy-MM-dd)") final String fromDate,
@QueryParam("toDate") @Parameter(description = "Sessions sent on/before this date (yyyy-MM-dd)") final String toDate,
@QueryParam("batchReference") final String batchReference, @QueryParam("offset") @DefaultValue("0") final Integer offset,
@QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_OUTWARD_PRESENTMENT);
final int safeLimit = Math.min(limit, 200);
return this.readPlatformService.retrieveOutwardBatches(fromDate != null ? LocalDate.parse(fromDate) : null,
toDate != null ? LocalDate.parse(toDate) : null, batchReference, offset, safeLimit);
}
@GET
@Path("{id}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an outward clearing presentment")
public OutwardClearingPresentmentData retrieveOne(@PathParam("id") @Parameter(description = "id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_OUTWARD_PRESENTMENT);
return this.readPlatformService.retrieveOutward(id);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Capture an outward clearing presentment")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createOutwardClearingPresentment().withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("send-pending-batch")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Send every pending (CAPTURED) outward presentment to NPCI as one combined CTS batch file",
description = "Bundles all currently CAPTURED cheques into a single batchHeader/instruments/batchFooter file, "
+ "signs and encrypts it once, and marks every included cheque SENT. Mirrors what the (disabled by "
+ "default) clearing-session scheduled job would do; safe to call with nothing pending (no-op).")
public CommandProcessingResult sendPendingBatch(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().sendPendingOutwardClearingBatch().withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a command on an outward clearing presentment", description = "Supported commands: send, settle, dishonour.")
public CommandProcessingResult handleCommand(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("command") @Parameter(description = "send | settle | dishonour") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
final CommandWrapper request;
if ("send".equalsIgnoreCase(commandParam)) {
request = builder.sendOutwardClearingPresentment(id).build();
} else if ("settle".equalsIgnoreCase(commandParam)) {
request = builder.settleOutwardClearingPresentment(id).build();
} else if ("dishonour".equalsIgnoreCase(commandParam)) {
request = builder.dishonourOutwardClearingPresentment(id).build();
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
/**
* Uploads a real scanned cheque image (front or back) for this presentment — stored via
* {@code ContentStoreService}, same convention as {@link InwardClearingItemApiResource#uploadImage}. Once
* uploaded, the image is embedded (base64) in the CTS file sent to NPCI when this cheque is sent.
*/
@POST
@Path("{id}/image")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Upload the front or back cheque image for an outward presentment")
public Response uploadImage(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("side") @Parameter(description = "front | back") final String side,
@FormDataParam("file") final InputStream fileStream, @FormDataParam("file") final FormDataContentDisposition fileDetail,
@FormDataParam("file") final FormDataBodyPart filePart) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_OUTWARD_PRESENTMENT);
CtsImageUtil.validateSide(side);
final String mimeType = filePart != null && filePart.getMediaType() != null ? filePart.getMediaType().toString() : "image/png";
final String ext = CtsImageUtil.extractExt(fileDetail != null ? fileDetail.getFileName() : null);
final String storagePath = "images" + this.contentStoreService.getDelimiter() + "ctsclearing-outward"
+ this.contentStoreService.getDelimiter() + id + this.contentStoreService.getDelimiter() + side.toLowerCase() + ext;
final String savedPath = this.contentStoreService.upload(storagePath, fileStream, mimeType);
this.writePlatformService.recordOutwardPresentmentImage(id, side, mimeType + "|" + savedPath);
return Response.ok(Map.of("resourceId", id)).build();
}
/** Returns the front/back cheque image as a base64 data URL — see {@link InwardClearingItemApiResource#getImage}. */
@GET
@Path("{id}/image")
@Produces(MediaType.TEXT_PLAIN)
@Operation(summary = "Retrieve the front or back cheque image for an outward presentment as a base64 data URL")
public Response getImage(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("side") @Parameter(description = "front | back") final String side) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_OUTWARD_PRESENTMENT);
CtsImageUtil.validateSide(side);
final String store = this.readPlatformService.retrieveOutwardPresentmentImageStore(id, side);
if (store == null || store.isBlank() || !store.contains("|")) {
return Response.status(Response.Status.NOT_FOUND).build();
}
final String[] parts = store.split("\\|", 2);
try {
final byte[] bytes = this.contentStoreService.download(parts[1]).readAllBytes();
final String dataUrl = "data:" + parts[0] + ";base64," + Base64.getEncoder().encodeToString(bytes);
return Response.ok(dataUrl).build();
} catch (final Exception e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
}
@@ -0,0 +1,102 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.ctsclearing.data.PositivePayRegistrationData;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/ctsclearing/positive-pay")
@Component
@Tag(name = "Positive Pay (RBI PPS)", description = "Pre-registration of high-value cheque particulars; inward clearing matches a "
+ "presented high-value cheque against an active registration.")
@RequiredArgsConstructor
public class PositivePayApiResource {
private final PlatformSecurityContext context;
private final CtsClearingReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Positive Pay registrations")
public Page<PositivePayRegistrationData> retrieveAll(@QueryParam("accountId") final Long accountId,
@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final Integer offset,
@QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_POSITIVE_PAY);
final int safeLimit = Math.min(limit, 200);
return this.readPlatformService.retrievePositivePayRegistrations(accountId, status, offset, safeLimit);
}
@GET
@Path("{id}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a Positive Pay registration")
public PositivePayRegistrationData retrieveOne(@PathParam("id") @Parameter(description = "id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(CtsClearingApiConstants.ENTITY_POSITIVE_PAY);
return this.readPlatformService.retrievePositivePayRegistration(id);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Register a high-value cheque under Positive Pay")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createPositivePayRegistration().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a command on a Positive Pay registration", description = "Supported commands: cancel.")
public CommandProcessingResult handleCommand(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("command") @Parameter(description = "cancel") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
final CommandWrapper request;
if ("cancel".equalsIgnoreCase(commandParam)) {
request = builder.cancelPositivePayRegistration(id).build();
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,53 @@
/**
* 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.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class InwardClearingBatchData {
private final Long id;
private final String batchReference;
private final LocalDate sessionDate;
private final String presentmentType;
private final String sourceFile;
private final Integer totalItemCount;
private final BigDecimal totalAmount;
private final String status;
private final Long officeId;
private final String officeName;
private final Long receivedBy;
private final OffsetDateTime receivedOn;
private final OffsetDateTime validatedOn;
private final OffsetDateTime decisionedOn;
private final OffsetDateTime settledOn;
private final OffsetDateTime createdOn;
private final String presentingBankCode;
private final String presentingBankName;
private final LocalDate returnTatDeadline;
}
@@ -0,0 +1,76 @@
/**
* 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.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class InwardClearingItemData {
private final Long id;
private final Long batchId;
private final Long chequeLeafId;
private final String accountType;
private final Long accountId;
private final String accountNo;
private final String clientName;
private final String chequeNumber;
private final String micrCode;
private final String ifsc;
private final String payeeName;
private final BigDecimal amount;
private final String amountInWords;
private final LocalDate instrumentDate;
private final LocalDate presentedDate;
private final String frontImageRef;
private final String backImageRef;
private final String iqaStatus;
private final String iqaDetail;
private final Boolean accountCheckPassed;
private final Boolean balanceCheckPassed;
private final Boolean signatureOk;
private final Boolean duplicateCheckPassed;
private final Boolean stopPaymentCheckPassed;
private final Boolean amountWordsFiguresMatch;
private final Boolean staleOrPostdated;
private final Boolean positivePayCheck;
private final String validationDetail;
private final String decision;
private final String returnReasonCode;
private final String decisionReason;
private final Long decisionedBy;
private final OffsetDateTime decisionedOn;
private final String itemStatus;
private final Boolean holdPlaced;
private final BigDecimal holdAmount;
private final Long settlementBatchId;
private final String originalSerialNumber;
private final String presentingBranchCode;
private final LocalDate returnDeadline;
private final String ctsReferenceNumber;
private final Boolean instrumentStopPaymentFlag;
private final LocalDate depositDate;
}
@@ -0,0 +1,46 @@
/**
* 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.data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
/**
* One row per outward clearing session ("batch") — every {@code OutwardClearingPresentment} that shares one
* {@code batchReference}, grouped at read time. There is no dedicated batch table: the session boundary is
* whichever cheques were still CAPTURED the moment the 2-hourly clearing job (or the manual "Send Pending
* Batch" fallback) fired.
*/
@Getter
@Builder
@AllArgsConstructor
public class OutwardClearingBatchSummaryData {
private final String batchReference;
private final OffsetDateTime sessionStartedOn;
private final OffsetDateTime sessionEndedOn;
private final Integer itemCount;
private final BigDecimal totalAmount;
private final Integer sentCount;
private final Integer settledCount;
private final Integer dishonouredCount;
}
@@ -0,0 +1,64 @@
/**
* 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.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class OutwardClearingPresentmentData {
private final Long id;
private final String depositingAccountType;
private final Long depositingAccountId;
private final String accountNo;
private final String clientName;
private final String drawerBankMicr;
private final String drawerIfsc;
private final String drawerBankName;
private final String chequeNumber;
private final BigDecimal amount;
private final String payeeName;
private final LocalDate depositedDate;
private final LocalDate instrumentDate;
private final Boolean stopPaymentFlag;
private final String frontImageRef;
private final String backImageRef;
private final String iqaStatus;
private final String iqaDetail;
private final String drawerAccountNumber;
private final String gridZoneCode;
private final String gridZoneName;
private final String status;
private final String batchReference;
private final String ctsFileJson;
private final String npciResponseJson;
private final OffsetDateTime sentOn;
private final String dishonourReasonCode;
private final Long officeId;
private final OffsetDateTime settledOn;
private final Long createdBy;
private final OffsetDateTime createdOn;
}
@@ -0,0 +1,47 @@
/**
* 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.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class PositivePayRegistrationData {
private final Long id;
private final String accountType;
private final Long accountId;
private final String accountNo;
private final String clientName;
private final String chequeNumber;
private final LocalDate instrumentDate;
private final BigDecimal amount;
private final String payeeName;
private final String channel;
private final String status;
private final Long registeredBy;
private final OffsetDateTime registeredOn;
private final String cancelledReason;
}
@@ -0,0 +1,157 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A received (simulated) inward clearing session — a set of cheques drawn on THIS bank presented by the
* clearing house. {@code officeId} is a plain column rather than a JPA relation, mirroring
* {@code ChequeBook.accountId}'s "plain column to avoid a cross-module entity dependency" precedent.
*/
@Entity
@Table(name = "m_inward_clearing_batch")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class InwardClearingBatch extends AbstractPersistableCustom<Long> {
@Column(name = "batch_reference", nullable = false, length = 100)
private String batchReference;
@Column(name = "session_date", nullable = false)
private LocalDate sessionDate;
@Column(name = "presentment_type", nullable = false, length = 10)
private String presentmentType;
@Column(name = "source_file", length = 200)
private String sourceFile;
/** Who presented this batch to us, as captured on receipt — optional, since we cannot verify it ourselves. */
@Column(name = "presenting_bank_code", length = 15)
private String presentingBankCode;
@Column(name = "presenting_bank_name", length = 150)
private String presentingBankName;
/** RBI CTS return TAT (T+1) for every item in this batch, unless a specific item overrides it. */
@Column(name = "return_tat_deadline")
private LocalDate returnTatDeadline;
@Column(name = "total_item_count", nullable = false)
private Integer totalItemCount;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Column(name = "status", nullable = false, length = 15)
private String status;
@Column(name = "office_id")
private Long officeId;
@Column(name = "received_by")
private Long receivedBy;
@Column(name = "received_on")
private OffsetDateTime receivedOn;
@Column(name = "validated_on")
private OffsetDateTime validatedOn;
@Column(name = "decisioned_on")
private OffsetDateTime decisionedOn;
@Column(name = "settled_on")
private OffsetDateTime settledOn;
@Column(name = "created_on")
private OffsetDateTime createdOn;
@Version
@Column(name = "version")
private Integer version;
public static InwardClearingBatch receive(final String batchReference, final LocalDate sessionDate, final String presentmentType,
final String sourceFile, final String presentingBankCode, final String presentingBankName, final Long officeId,
final Long receivedBy) {
final InwardClearingBatch batch = new InwardClearingBatch();
batch.batchReference = batchReference;
batch.sessionDate = sessionDate;
batch.presentmentType = presentmentType == null ? "MICR" : presentmentType;
batch.sourceFile = sourceFile;
batch.presentingBankCode = presentingBankCode;
batch.presentingBankName = presentingBankName;
batch.officeId = officeId;
batch.receivedBy = receivedBy;
batch.totalItemCount = 0;
batch.totalAmount = BigDecimal.ZERO;
batch.status = InwardClearingBatchStatus.RECEIVED.name();
batch.receivedOn = DateUtils.getOffsetDateTimeOfTenant();
batch.createdOn = DateUtils.getOffsetDateTimeOfTenant();
// RBI CTS return TAT: T+1 from the clearing session date
batch.returnTatDeadline = sessionDate == null ? null : sessionDate.plusDays(1);
return batch;
}
/** Set once the items have been inserted, so the batch summary matches the sum of its items. */
public void setTotals(final int totalItemCount, final BigDecimal totalAmount) {
this.totalItemCount = totalItemCount;
this.totalAmount = totalAmount == null ? BigDecimal.ZERO : totalAmount;
}
public boolean isReceived() {
return InwardClearingBatchStatus.RECEIVED.name().equals(this.status);
}
public boolean isValidated() {
return InwardClearingBatchStatus.VALIDATED.name().equals(this.status);
}
public void markValidated() {
this.status = InwardClearingBatchStatus.VALIDATED.name();
this.validatedOn = DateUtils.getOffsetDateTimeOfTenant();
}
public void markDecisioned() {
this.status = InwardClearingBatchStatus.DECISIONED.name();
this.decisionedOn = DateUtils.getOffsetDateTimeOfTenant();
}
public void markSettled() {
if (!InwardClearingBatchStatus.DECISIONED.name().equals(this.status)) {
throw new GeneralPlatformDomainRuleException("error.msg.inward.clearing.batch.not.decisioned",
"Inward clearing batch " + getId() + " is not in DECISIONED status.", getId());
}
this.status = InwardClearingBatchStatus.SETTLED.name();
this.settledOn = DateUtils.getOffsetDateTimeOfTenant();
}
}
@@ -0,0 +1,26 @@
/**
* 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.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InwardClearingBatchRepository extends JpaRepository<InwardClearingBatch, Long> {
boolean existsByBatchReferenceIgnoreCase(String batchReference);
}
@@ -0,0 +1,38 @@
/**
* 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.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.ctsclearing.exception.InwardClearingBatchNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class InwardClearingBatchRepositoryWrapper {
private final InwardClearingBatchRepository repository;
public InwardClearingBatch findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new InwardClearingBatchNotFoundException(id));
}
public InwardClearingBatch saveAndFlush(final InwardClearingBatch batch) {
return this.repository.saveAndFlush(batch);
}
}
@@ -0,0 +1,32 @@
/**
* 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.domain;
/**
* Lifecycle of an inward clearing batch: RECEIVED (items captured) -&gt; VALIDATED (rules run, holds placed)
* -&gt; DECISIONED (every item passed or returned) -&gt; SETTLED (netting done — out of scope of this module,
* kept for completeness).
*/
public enum InwardClearingBatchStatus {
RECEIVED, //
VALIDATED, //
DECISIONED, //
SETTLED; //
}
@@ -0,0 +1,331 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One cheque within an inward clearing batch, carrying its captured MICR data, the result of each validation
* rule and the eventual pass/return decision. {@code chequeLeafId} and {@code accountId} are plain columns
* (no JPA relation) — the account may be a {@code SavingsAccount} or a {@code CurrentAccount}, and the leaf
* lives in the {@code chequebook} module — mirroring {@code ChequeBook}'s plain-column precedent.
*/
@Entity
@Table(name = "m_inward_clearing_item")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class InwardClearingItem extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "batch_id", nullable = false)
private InwardClearingBatch batch;
@Column(name = "cheque_leaf_id")
private Long chequeLeafId;
@Column(name = "account_type", length = 10)
private String accountType;
@Column(name = "account_id")
private Long accountId;
@Column(name = "cheque_number", nullable = false, length = 25)
private String chequeNumber;
@Column(name = "micr_code", length = 20)
private String micrCode;
@Column(name = "ifsc", length = 15)
private String ifsc;
@Column(name = "payee_name", length = 150)
private String payeeName;
@Column(name = "amount", nullable = false)
private BigDecimal amount;
@Column(name = "amount_in_words", length = 300)
private String amountInWords;
@Column(name = "instrument_date")
private LocalDate instrumentDate;
@Column(name = "presented_date")
private LocalDate presentedDate;
/** This cheque's serial number within Bank A's original outward batch, if supplied on receipt. */
@Column(name = "original_serial_number", length = 20)
private String originalSerialNumber;
/** The branch of the presenting (collecting) bank that originally deposited this cheque, if supplied. */
@Column(name = "presenting_branch_code", length = 20)
private String presentingBranchCode;
/** RBI CTS return TAT (T+1) — the deadline by which a RETURN decision must be dispatched. */
@Column(name = "return_deadline")
private LocalDate returnDeadline;
/** The sending bank's own CTS-2010 reference number for this instrument, if supplied on receipt. */
@Column(name = "cts_reference_number", length = 50)
private String ctsReferenceNumber;
/** Stop-payment flag as captured on the DEPOSITING side, if supplied — descriptive only; the authoritative
* check is {@code stopPaymentCheckPassed}, resolved against this bank's own {@code ChequeLeaf}. */
@Column(name = "instrument_stop_payment_flag")
private Boolean instrumentStopPaymentFlag;
/** The date the depositing bank's customer actually deposited this cheque, if supplied on receipt. */
@Column(name = "deposit_date")
private LocalDate depositDate;
@Column(name = "front_image_ref", length = 300)
private String frontImageRef;
@Column(name = "back_image_ref", length = 300)
private String backImageRef;
/** Real uploaded image, as "mimeType|storagePath" (via ContentStoreService) — null until a file is uploaded. */
@Column(name = "front_image_store", length = 500)
private String frontImageStore;
@Column(name = "back_image_store", length = 500)
private String backImageStore;
/** Image Quality Assessment outcome: PASS / FAIL / PENDING / NA. */
@Column(name = "iqa_status", length = 10)
private String iqaStatus;
@Column(name = "iqa_detail", length = 500)
private String iqaDetail;
@Column(name = "account_check_passed")
private Boolean accountCheckPassed;
@Column(name = "balance_check_passed")
private Boolean balanceCheckPassed;
@Column(name = "signature_ok")
private Boolean signatureOk;
@Column(name = "duplicate_check_passed")
private Boolean duplicateCheckPassed;
@Column(name = "stop_payment_check_passed")
private Boolean stopPaymentCheckPassed;
@Column(name = "amount_words_figures_match")
private Boolean amountWordsFiguresMatch;
@Column(name = "stale_or_postdated")
private Boolean staleOrPostdated;
/** Positive Pay match result for a high-value cheque: TRUE match, FALSE mismatch/unregistered, null = not applicable. */
@Column(name = "positive_pay_check")
private Boolean positivePayCheck;
@Column(name = "validation_detail", length = 1000)
private String validationDetail;
@Column(name = "decision", nullable = false, length = 10)
private String decision;
@Column(name = "return_reason_code", length = 10)
private String returnReasonCode;
@Column(name = "decision_reason", length = 500)
private String decisionReason;
@Column(name = "decisioned_by")
private Long decisionedBy;
@Column(name = "decisioned_on")
private OffsetDateTime decisionedOn;
@Column(name = "item_status", nullable = false, length = 15)
private String itemStatus;
@Column(name = "hold_placed", nullable = false)
private boolean holdPlaced;
@Column(name = "hold_amount")
private BigDecimal holdAmount;
@Column(name = "settlement_batch_id")
private Long settlementBatchId;
@Column(name = "created_on")
private OffsetDateTime createdOn;
@Version
@Column(name = "version")
private Integer version;
public static InwardClearingItem receive(final InwardClearingBatch batch, final Long chequeLeafId, final String accountType,
final Long accountId, final String chequeNumber, final String micrCode, final String ifsc, final String payeeName,
final BigDecimal amount, final String amountInWords, final LocalDate instrumentDate, final LocalDate presentedDate) {
final InwardClearingItem item = new InwardClearingItem();
item.batch = batch;
item.chequeLeafId = chequeLeafId;
item.accountType = accountType;
item.accountId = accountId;
item.chequeNumber = chequeNumber;
item.micrCode = micrCode;
item.ifsc = ifsc;
item.payeeName = payeeName;
item.amount = amount == null ? BigDecimal.ZERO : amount;
item.amountInWords = amountInWords;
item.instrumentDate = instrumentDate;
item.presentedDate = presentedDate;
item.decision = InwardClearingItemDecision.PENDING.name();
item.itemStatus = InwardClearingItemStatus.RECEIVED.name();
item.holdPlaced = false;
item.iqaStatus = "NA";
item.createdOn = DateUtils.getOffsetDateTimeOfTenant();
return item;
}
/** Records the source-bank traceability fields NPCI's inward package carries for this item, if supplied. */
public void recordInwardMeta(final String originalSerialNumber, final String presentingBranchCode, final LocalDate returnDeadline) {
this.originalSerialNumber = originalSerialNumber;
this.presentingBranchCode = presentingBranchCode;
this.returnDeadline = returnDeadline;
}
/** Records additional descriptive fields carried on the instrument from the depositing/sending side, if supplied. */
public void recordSourceDetails(final String ctsReferenceNumber, final Boolean instrumentStopPaymentFlag,
final LocalDate depositDate) {
this.ctsReferenceNumber = ctsReferenceNumber;
this.instrumentStopPaymentFlag = instrumentStopPaymentFlag;
this.depositDate = depositDate;
}
/**
* 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.
*/
public void captureImages(final String frontImageRef, final String backImageRef, final String iqaStatus, final String iqaDetail) {
this.frontImageRef = frontImageRef;
this.backImageRef = backImageRef;
this.iqaStatus = iqaStatus == null || iqaStatus.isBlank() ? "NA" : iqaStatus.trim().toUpperCase();
this.iqaDetail = iqaDetail;
}
/** True when the cheque image failed the quality assessment — a valid ground for a technical return (code 11). */
public boolean isImageQualityFailed() {
return "FAIL".equalsIgnoreCase(this.iqaStatus);
}
/** Records where a real uploaded front/back image lives, as "mimeType|storagePath". */
public void recordImageStore(final String side, final String mimeTypeAndPath) {
if ("front".equalsIgnoreCase(side)) {
this.frontImageStore = mimeTypeAndPath;
} else {
this.backImageStore = mimeTypeAndPath;
}
}
// ============================= Validation =============================
/** Records the outcome of the automatic validation rules run by {@code InwardClearingValidationService}. */
public void recordValidation(final Boolean accountCheckPassed, final Boolean balanceCheckPassed, final Boolean duplicateCheckPassed,
final Boolean stopPaymentCheckPassed, final Boolean amountWordsFiguresMatch, final Boolean staleOrPostdated,
final String validationDetail) {
this.accountCheckPassed = accountCheckPassed;
this.balanceCheckPassed = balanceCheckPassed;
this.duplicateCheckPassed = duplicateCheckPassed;
this.stopPaymentCheckPassed = stopPaymentCheckPassed;
this.amountWordsFiguresMatch = amountWordsFiguresMatch;
this.staleOrPostdated = staleOrPostdated;
this.validationDetail = validationDetail;
}
/** Records the Positive Pay match outcome (RBI PPS) for a high-value cheque. */
public void recordPositivePay(final Boolean positivePayCheck) {
this.positivePayCheck = positivePayCheck;
}
public void placeHold(final BigDecimal holdAmount) {
this.holdPlaced = true;
this.holdAmount = holdAmount;
}
/** Clears the hold flag once the placed hold has been consumed (pass) or released (return). */
public void clearHold() {
this.holdPlaced = false;
}
public void markValidated() {
this.itemStatus = InwardClearingItemStatus.VALIDATED.name();
}
public boolean isPending() {
return InwardClearingItemDecision.PENDING.name().equals(this.decision);
}
public boolean isValidated() {
return InwardClearingItemStatus.VALIDATED.name().equals(this.itemStatus);
}
private void assertDecidable() {
if (!isValidated()) {
throw new GeneralPlatformDomainRuleException("error.msg.inward.clearing.item.not.validated",
"Inward clearing item " + getId() + " has not been validated.", getId());
}
if (!isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.inward.clearing.item.already.decided",
"Inward clearing item " + getId() + " has already been decided.", getId());
}
}
// ============================= Decision =============================
public void pass(final String reasonNote, final Long userId) {
assertDecidable();
this.decision = InwardClearingItemDecision.PASS.name();
this.decisionReason = reasonNote;
this.decisionedBy = userId;
this.decisionedOn = DateUtils.getOffsetDateTimeOfTenant();
this.itemStatus = InwardClearingItemStatus.PASSED.name();
}
public void returnItem(final String returnReasonCode, final String reason, final Long userId) {
assertDecidable();
this.decision = InwardClearingItemDecision.RETURN.name();
this.returnReasonCode = returnReasonCode;
this.decisionReason = reason;
this.decisionedBy = userId;
this.decisionedOn = DateUtils.getOffsetDateTimeOfTenant();
this.itemStatus = InwardClearingItemStatus.RETURNED.name();
}
}
@@ -0,0 +1,27 @@
/**
* 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.domain;
/** The back-office decision on a single inward item: PENDING until a user passes or returns it. */
public enum InwardClearingItemDecision {
PENDING, //
PASS, //
RETURN; //
}
@@ -0,0 +1,43 @@
/**
* 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.domain;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface InwardClearingItemRepository extends JpaRepository<InwardClearingItem, Long> {
List<InwardClearingItem> findByBatchIdOrderByIdAsc(Long batchId);
long countByBatchId(Long batchId);
long countByBatchIdAndDecision(Long batchId, String decision);
/**
* Any OTHER item (in any batch) with the same cheque number + account + amount that was already PASSED —
* used by the duplicate-presentment check.
*/
@Query("select count(i) from InwardClearingItem i where i.chequeNumber = :chequeNumber and i.accountId = :accountId "
+ "and i.amount = :amount and i.decision = 'PASS' and i.id <> :itemId")
long countPassedDuplicates(@Param("chequeNumber") String chequeNumber, @Param("accountId") Long accountId,
@Param("amount") BigDecimal amount, @Param("itemId") Long itemId);
}
@@ -0,0 +1,38 @@
/**
* 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.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.ctsclearing.exception.InwardClearingItemNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class InwardClearingItemRepositoryWrapper {
private final InwardClearingItemRepository repository;
public InwardClearingItem findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new InwardClearingItemNotFoundException(id));
}
public InwardClearingItem saveAndFlush(final InwardClearingItem item) {
return this.repository.saveAndFlush(item);
}
}
@@ -0,0 +1,29 @@
/**
* 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.domain;
/** Per-item lifecycle: RECEIVED -&gt; VALIDATED -&gt; PASSED / RETURNED (-&gt; SETTLED once netted). */
public enum InwardClearingItemStatus {
RECEIVED, //
VALIDATED, //
PASSED, //
RETURNED, //
SETTLED; //
}
@@ -0,0 +1,219 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A cheque one of our own customers deposited, drawn on ANOTHER bank, captured for onward presentation to the
* clearing house. A flat entity with a manually-advanced status. {@code depositingAccountId} is a plain
* column (Savings or Current), mirroring {@code ChequeBook}'s plain-column precedent.
*/
@Entity
@Table(name = "m_outward_clearing_presentment")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class OutwardClearingPresentment extends AbstractPersistableCustom<Long> {
@Column(name = "depositing_account_type", nullable = false, length = 10)
private String depositingAccountType;
@Column(name = "depositing_account_id", nullable = false)
private Long depositingAccountId;
@Column(name = "drawer_bank_micr", length = 20)
private String drawerBankMicr;
@Column(name = "drawer_ifsc", length = 15)
private String drawerIfsc;
@Column(name = "drawer_bank_name", length = 150)
private String drawerBankName;
@Column(name = "cheque_number", nullable = false, length = 25)
private String chequeNumber;
@Column(name = "amount", nullable = false)
private BigDecimal amount;
@Column(name = "payee_name", length = 150)
private String payeeName;
@Column(name = "deposited_date", nullable = false)
private LocalDate depositedDate;
@Column(name = "instrument_date")
private LocalDate instrumentDate;
/** Captured at deposit time, drives the CTS outward file's stopPaymentFlag instead of a hardcoded false. */
@Column(name = "stop_payment_flag", nullable = false)
private boolean stopPaymentFlag;
@Column(name = "front_image_ref", length = 300)
private String frontImageRef;
@Column(name = "back_image_ref", length = 300)
private String backImageRef;
/** Image Quality Assessment outcome: PASS / FAIL / PENDING / NA. */
@Column(name = "iqa_status", length = 10)
private String iqaStatus;
@Column(name = "iqa_detail", length = 500)
private String iqaDetail;
/** Real uploaded image, as "mimeType|storagePath" (via ContentStoreService) — null until a file is uploaded. */
@Column(name = "front_image_store", length = 500)
private String frontImageStore;
@Column(name = "back_image_store", length = 500)
private String backImageStore;
/** The drawer's account number at the drawee bank (the account being debited) — a distinct field from
* {@code depositingAccountId}, which is OUR customer's account that received the deposit. */
@Column(name = "drawer_account_number", length = 34)
private String drawerAccountNumber;
@Column(name = "grid_zone_code", length = 10)
private String gridZoneCode;
@Column(name = "grid_zone_name", length = 100)
private String gridZoneName;
@Column(name = "status", nullable = false, length = 25)
private String status;
@Column(name = "batch_reference", length = 100)
private String batchReference;
/** The full, human-readable CTS-2010 outward file (batchHeader + instrument + batchFooter) that was sent. */
@Column(name = "cts_file_json", columnDefinition = "TEXT")
private String ctsFileJson;
/** NPCI's real routing response (signatureValid, routedTo, unroutable) for this batch, from the actual NPCI service. */
@Column(name = "npci_response_json", columnDefinition = "TEXT")
private String npciResponseJson;
@Column(name = "sent_on")
private OffsetDateTime sentOn;
@Column(name = "dishonour_reason_code", length = 10)
private String dishonourReasonCode;
@Column(name = "office_id")
private Long officeId;
@Column(name = "settled_on")
private OffsetDateTime settledOn;
@Column(name = "created_by")
private Long createdBy;
@Column(name = "created_on")
private OffsetDateTime createdOn;
@Version
@Column(name = "version")
private Integer version;
public static OutwardClearingPresentment capture(final String depositingAccountType, final Long depositingAccountId,
final String drawerBankMicr, final String drawerIfsc, final String drawerBankName, final String chequeNumber,
final BigDecimal amount, final String payeeName, final LocalDate depositedDate, final LocalDate instrumentDate,
final Long officeId, final Long createdBy, final Boolean stopPaymentFlag, final String frontImageRef,
final String backImageRef, final String iqaStatus, final String iqaDetail, final String drawerAccountNumber,
final String gridZoneCode, final String gridZoneName) {
final OutwardClearingPresentment presentment = new OutwardClearingPresentment();
presentment.depositingAccountType = depositingAccountType;
presentment.depositingAccountId = depositingAccountId;
presentment.drawerBankMicr = drawerBankMicr;
presentment.drawerIfsc = drawerIfsc;
presentment.drawerBankName = drawerBankName;
presentment.chequeNumber = chequeNumber;
presentment.amount = amount == null ? BigDecimal.ZERO : amount;
presentment.payeeName = payeeName;
presentment.depositedDate = depositedDate;
presentment.instrumentDate = instrumentDate;
presentment.officeId = officeId;
presentment.createdBy = createdBy;
presentment.stopPaymentFlag = Boolean.TRUE.equals(stopPaymentFlag);
presentment.frontImageRef = frontImageRef;
presentment.backImageRef = backImageRef;
presentment.iqaStatus = iqaStatus == null || iqaStatus.isBlank() ? "NA" : iqaStatus.trim().toUpperCase();
presentment.iqaDetail = iqaDetail;
presentment.drawerAccountNumber = drawerAccountNumber;
presentment.gridZoneCode = gridZoneCode;
presentment.gridZoneName = gridZoneName;
presentment.status = OutwardClearingPresentmentStatus.CAPTURED.name();
presentment.createdOn = DateUtils.getOffsetDateTimeOfTenant();
return presentment;
}
private void assertStatus(final OutwardClearingPresentmentStatus expected, final String errorCode) {
if (!expected.name().equals(this.status)) {
throw new GeneralPlatformDomainRuleException(errorCode,
"Outward clearing presentment " + getId() + " is not in " + expected.name() + " status.", getId());
}
}
public void send(final String batchReference, final String ctsFileJson) {
assertStatus(OutwardClearingPresentmentStatus.CAPTURED, "error.msg.outward.clearing.presentment.not.captured");
this.status = OutwardClearingPresentmentStatus.SENT_TO_CLEARING_HOUSE.name();
this.batchReference = batchReference;
this.ctsFileJson = ctsFileJson;
this.sentOn = DateUtils.getOffsetDateTimeOfTenant();
}
/** Records NPCI's real routing response for the batch this presentment was just sent in. */
public void recordNpciResponse(final String npciResponseJson) {
this.npciResponseJson = npciResponseJson;
}
public void settle() {
assertStatus(OutwardClearingPresentmentStatus.SENT_TO_CLEARING_HOUSE, "error.msg.outward.clearing.presentment.not.sent");
this.status = OutwardClearingPresentmentStatus.SETTLED.name();
this.settledOn = DateUtils.getOffsetDateTimeOfTenant();
}
public void dishonour(final String dishonourReasonCode) {
assertStatus(OutwardClearingPresentmentStatus.SENT_TO_CLEARING_HOUSE, "error.msg.outward.clearing.presentment.not.sent");
this.status = OutwardClearingPresentmentStatus.DISHONOURED.name();
this.dishonourReasonCode = dishonourReasonCode;
}
/** Records where a real uploaded front/back image lives, as "mimeType|storagePath". */
public void recordImageStore(final String side, final String mimeTypeAndPath) {
if ("front".equalsIgnoreCase(side)) {
this.frontImageStore = mimeTypeAndPath;
} else {
this.backImageStore = mimeTypeAndPath;
}
}
}
@@ -0,0 +1,27 @@
/**
* 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.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OutwardClearingPresentmentRepository extends JpaRepository<OutwardClearingPresentment, Long> {
List<OutwardClearingPresentment> findByStatusOrderByIdAsc(String status);
}
@@ -0,0 +1,38 @@
/**
* 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.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.ctsclearing.exception.OutwardClearingPresentmentNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class OutwardClearingPresentmentRepositoryWrapper {
private final OutwardClearingPresentmentRepository repository;
public OutwardClearingPresentment findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new OutwardClearingPresentmentNotFoundException(id));
}
public OutwardClearingPresentment saveAndFlush(final OutwardClearingPresentment presentment) {
return this.repository.saveAndFlush(presentment);
}
}
@@ -0,0 +1,31 @@
/**
* 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.domain;
/**
* Manually-advanced lifecycle of an outward presentment (a cheque our customer deposited, drawn on another
* bank): CAPTURED -&gt; SENT_TO_CLEARING_HOUSE -&gt; SETTLED, or -&gt; DISHONOURED if the drawer bank bounces it.
*/
public enum OutwardClearingPresentmentStatus {
CAPTURED, //
SENT_TO_CLEARING_HOUSE, //
SETTLED, //
DISHONOURED; //
}
@@ -0,0 +1,140 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A Positive Pay registration: the drawer pre-declares a high-value cheque's key particulars (number, date,
* amount, payee) so inward clearing can confirm the presented cheque matches what the drawer authorised
* (RBI Positive Pay System). {@code accountId} is a plain column (savings or current), following
* {@code ChequeBook}'s precedent.
*/
@Entity
@Table(name = "m_positive_pay_registration")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class PositivePayRegistration extends AbstractPersistableCustom<Long> {
@Column(name = "account_type", nullable = false, length = 10)
private String accountType;
@Column(name = "account_id", nullable = false)
private Long accountId;
@Column(name = "cheque_number", nullable = false, length = 25)
private String chequeNumber;
@Column(name = "instrument_date")
private LocalDate instrumentDate;
@Column(name = "amount", nullable = false)
private BigDecimal amount;
@Column(name = "payee_name", length = 150)
private String payeeName;
@Column(name = "channel", length = 20)
private String channel;
@Column(name = "status", nullable = false, length = 15)
private String status;
@Column(name = "registered_by")
private Long registeredBy;
@Column(name = "registered_on")
private OffsetDateTime registeredOn;
@Column(name = "cancelled_reason", length = 500)
private String cancelledReason;
@Column(name = "cancelled_by")
private Long cancelledBy;
@Column(name = "cancelled_on")
private OffsetDateTime cancelledOn;
@Column(name = "created_on")
private OffsetDateTime createdOn;
@Version
@Column(name = "version")
private Integer version;
public static PositivePayRegistration register(final String accountType, final Long accountId, final String chequeNumber,
final LocalDate instrumentDate, final BigDecimal amount, final String payeeName, final String channel,
final Long registeredBy) {
final PositivePayRegistration pps = new PositivePayRegistration();
pps.accountType = accountType;
pps.accountId = accountId;
pps.chequeNumber = chequeNumber;
pps.instrumentDate = instrumentDate;
pps.amount = amount;
pps.payeeName = payeeName;
pps.channel = channel == null || channel.isBlank() ? "BRANCH" : channel;
pps.status = PositivePayStatus.ACTIVE.name();
pps.registeredBy = registeredBy;
pps.registeredOn = DateUtils.getOffsetDateTimeOfTenant();
pps.createdOn = DateUtils.getOffsetDateTimeOfTenant();
return pps;
}
public boolean isActive() {
return PositivePayStatus.ACTIVE.name().equals(this.status);
}
public void cancel(final String reason, final Long userId) {
if (!isActive()) {
throw new GeneralPlatformDomainRuleException("error.msg.positivepay.not.active",
"Positive Pay registration " + getId() + " is not ACTIVE.", getId());
}
this.status = PositivePayStatus.CANCELLED.name();
this.cancelledReason = reason;
this.cancelledBy = userId;
this.cancelledOn = DateUtils.getOffsetDateTimeOfTenant();
}
/** Whether the presented cheque's particulars match this registration (amount exact; payee/date if both present). */
public boolean matches(final BigDecimal presentedAmount, final LocalDate presentedInstrumentDate, final String presentedPayee) {
if (presentedAmount == null || this.amount.compareTo(presentedAmount) != 0) {
return false;
}
if (this.instrumentDate != null && presentedInstrumentDate != null && !this.instrumentDate.equals(presentedInstrumentDate)) {
return false;
}
if (this.payeeName != null && !this.payeeName.isBlank() && presentedPayee != null && !presentedPayee.isBlank()
&& !this.payeeName.trim().equalsIgnoreCase(presentedPayee.trim())) {
return false;
}
return true;
}
}
@@ -0,0 +1,27 @@
/**
* 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.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PositivePayRegistrationRepository extends JpaRepository<PositivePayRegistration, Long> {
List<PositivePayRegistration> findByAccountIdAndChequeNumberAndStatus(Long accountId, String chequeNumber, String status);
}
@@ -0,0 +1,38 @@
/**
* 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.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.ctsclearing.exception.PositivePayRegistrationNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PositivePayRegistrationRepositoryWrapper {
private final PositivePayRegistrationRepository repository;
public PositivePayRegistration findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new PositivePayRegistrationNotFoundException(id));
}
public PositivePayRegistration saveAndFlush(final PositivePayRegistration registration) {
return this.repository.saveAndFlush(registration);
}
}
@@ -0,0 +1,26 @@
/**
* 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.domain;
public enum PositivePayStatus {
ACTIVE, //
CANCELLED, //
CONSUMED; //
}
@@ -0,0 +1,28 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InwardClearingBatchNotFoundException extends AbstractPlatformResourceNotFoundException {
public InwardClearingBatchNotFoundException(final Long id) {
super("error.msg.inward.clearing.batch.id.invalid", "Inward clearing batch with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InwardClearingItemNotFoundException extends AbstractPlatformResourceNotFoundException {
public InwardClearingItemNotFoundException(final Long id) {
super("error.msg.inward.clearing.item.id.invalid", "Inward clearing item with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,29 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class OutwardClearingPresentmentNotFoundException extends AbstractPlatformResourceNotFoundException {
public OutwardClearingPresentmentNotFoundException(final Long id) {
super("error.msg.outward.clearing.presentment.id.invalid",
"Outward clearing presentment with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class PositivePayRegistrationNotFoundException extends AbstractPlatformResourceNotFoundException {
public PositivePayRegistrationNotFoundException(final Long id) {
super("error.msg.positivepay.id.invalid", "Positive Pay registration with identifier " + id + " does not exist", id);
}
}
@@ -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 = "POSITIVEPAYREGISTRATION", action = "CANCEL")
@RequiredArgsConstructor
public class CancelPositivePayRegistrationCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.cancelPositivePay(command.entityId(), command);
}
}
@@ -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 = "OUTWARDCLEARINGPRESENTMENT", action = "CREATE")
@RequiredArgsConstructor
public class CreateOutwardClearingPresentmentCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createOutward(command);
}
}
@@ -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 = "POSITIVEPAYREGISTRATION", action = "CREATE")
@RequiredArgsConstructor
public class CreatePositivePayRegistrationCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createPositivePay(command);
}
}
@@ -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 = "OUTWARDCLEARINGPRESENTMENT", action = "DISHONOUR")
@RequiredArgsConstructor
public class DishonourOutwardClearingPresentmentCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.dishonourOutward(command.entityId(), command);
}
}
@@ -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 = "PASS")
@RequiredArgsConstructor
public class PassInwardClearingItemCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.passItem(command.entityId(), command);
}
}
@@ -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 = "INWARDCLEARINGBATCH", action = "CREATE")
@RequiredArgsConstructor
public class ReceiveInwardClearingBatchCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.receiveBatch(command);
}
}
@@ -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 = "RETURN")
@RequiredArgsConstructor
public class ReturnInwardClearingItemCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.returnItem(command.entityId(), command);
}
}
@@ -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 = "OUTWARDCLEARINGPRESENTMENT", action = "SEND")
@RequiredArgsConstructor
public class SendOutwardClearingPresentmentCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.sendOutward(command.entityId(), command);
}
}
@@ -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 = "OUTWARDCLEARINGPRESENTMENT", action = "SENDBATCH")
@RequiredArgsConstructor
public class SendPendingOutwardClearingBatchCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.sendPendingOutwardBatch(command);
}
}
@@ -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 = "OUTWARDCLEARINGPRESENTMENT", action = "SETTLE")
@RequiredArgsConstructor
public class SettleOutwardClearingPresentmentCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.settleOutward(command.entityId(), command);
}
}
@@ -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 = "INWARDCLEARINGBATCH", action = "VALIDATE")
@RequiredArgsConstructor
public class ValidateInwardClearingBatchCommandHandler implements NewCommandSourceHandler {
private final CtsClearingWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.validateBatch(command.entityId(), command);
}
}
@@ -0,0 +1,61 @@
/**
* 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.job;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingWritePlatformService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
/** Registers the (seeded-inactive) "CTS Outward Clearing Batch Send" Spring Batch job — same shape as {@code NeftBatchProcessJobConfig}. */
@Configuration
public class OutwardClearingBatchJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private CtsClearingWritePlatformService service;
@Bean
public OutwardClearingBatchTasklet outwardClearingBatchTasklet() {
return new OutwardClearingBatchTasklet(service);
}
@Bean
protected Step outwardClearingBatchStep() {
return new StepBuilder(JobName.OUTWARD_CLEARING_BATCH_SEND.name(), jobRepository)
.tasklet(outwardClearingBatchTasklet(), transactionManager).build();
}
@Bean
public Job outwardClearingBatchJob() {
return new JobBuilder(JobName.OUTWARD_CLEARING_BATCH_SEND.name(), jobRepository).start(outwardClearingBatchStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,50 @@
/**
* 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.job;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.ctsclearing.service.CtsClearingWritePlatformService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Clearing-session batch job: bundles every still-CAPTURED outward presentment into one CTS file and sends it
* to NPCI, exactly like the manual "Send Pending Batch to NPCI" button (both call
* {@link CtsClearingWritePlatformService#sendPendingOutwardBatch}, so there is one copy of the business logic).
* Seeded INACTIVE by default (see the accompanying Liquibase changelog) — an operator opts in later from the
* standard Admin &gt; System &gt; Manage Jobs screen once ready to automate outward sends.
*/
@Slf4j
@RequiredArgsConstructor
public class OutwardClearingBatchTasklet implements Tasklet {
private final CtsClearingWritePlatformService service;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
final CommandProcessingResult result = this.service.sendPendingOutwardBatch(null);
final Long sent = result == null ? null : result.getResourceId();
log.info("[Job:OUTWARD_CLEARING_BATCH_SEND] cheques sent to NPCI in this run: {}", sent == null ? 0 : sent);
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,271 @@
/**
* 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.serialization;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.ctsclearing.api.CtsClearingApiConstants;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public final class CtsClearingCommandFromApiJsonDeserializer {
private final FromJsonHelper fromApiJsonHelper;
private static final Set<String> BATCH_CREATE_PARAMS = new HashSet<>(Arrays.asList(CtsClearingApiConstants.BATCH_REFERENCE,
CtsClearingApiConstants.SESSION_DATE, CtsClearingApiConstants.PRESENTMENT_TYPE, CtsClearingApiConstants.SOURCE_FILE,
CtsClearingApiConstants.PRESENTING_BANK_CODE, CtsClearingApiConstants.PRESENTING_BANK_NAME, CtsClearingApiConstants.OFFICE_ID,
CtsClearingApiConstants.ITEMS, CtsClearingApiConstants.LOCALE, CtsClearingApiConstants.DATE_FORMAT));
private static final Set<String> ITEM_PASS_PARAMS = new HashSet<>(
Arrays.asList(CtsClearingApiConstants.NOTE, CtsClearingApiConstants.LOCALE, CtsClearingApiConstants.DATE_FORMAT));
private static final Set<String> ITEM_RETURN_PARAMS = new HashSet<>(Arrays.asList(CtsClearingApiConstants.RETURN_REASON_CODE,
CtsClearingApiConstants.REASON, CtsClearingApiConstants.LOCALE, CtsClearingApiConstants.DATE_FORMAT));
private static final Set<String> OUTWARD_CREATE_PARAMS = new HashSet<>(Arrays.asList(CtsClearingApiConstants.DEPOSITING_ACCOUNT_TYPE,
CtsClearingApiConstants.DEPOSITING_ACCOUNT_ID, CtsClearingApiConstants.DRAWER_BANK_MICR, CtsClearingApiConstants.DRAWER_IFSC,
CtsClearingApiConstants.DRAWER_BANK_NAME, CtsClearingApiConstants.CHEQUE_NUMBER, CtsClearingApiConstants.AMOUNT,
CtsClearingApiConstants.PAYEE_NAME, CtsClearingApiConstants.DEPOSITED_DATE, CtsClearingApiConstants.INSTRUMENT_DATE,
CtsClearingApiConstants.STOP_PAYMENT_FLAG, CtsClearingApiConstants.FRONT_IMAGE_REF, CtsClearingApiConstants.BACK_IMAGE_REF,
CtsClearingApiConstants.IQA_STATUS, CtsClearingApiConstants.IQA_DETAIL, CtsClearingApiConstants.DRAWER_ACCOUNT_NUMBER,
CtsClearingApiConstants.GRID_ZONE_CODE, CtsClearingApiConstants.GRID_ZONE_NAME, CtsClearingApiConstants.OFFICE_ID,
CtsClearingApiConstants.LOCALE, CtsClearingApiConstants.DATE_FORMAT));
private static final Set<String> OUTWARD_DISHONOUR_PARAMS = new HashSet<>(Arrays.asList(CtsClearingApiConstants.DISHONOUR_REASON_CODE,
CtsClearingApiConstants.REASON, CtsClearingApiConstants.LOCALE, CtsClearingApiConstants.DATE_FORMAT));
public void validateForReceiveBatch(final String json) {
final JsonElement element = parse(json, BATCH_CREATE_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(CtsClearingApiConstants.RESOURCE_INWARD_BATCH);
final String batchReference = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.BATCH_REFERENCE, element);
v.reset().parameter(CtsClearingApiConstants.BATCH_REFERENCE).value(batchReference).notBlank().notExceedingLengthOf(100);
v.reset().parameter(CtsClearingApiConstants.SESSION_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(
CtsClearingApiConstants.SESSION_DATE, element)).notNull();
final String presentmentType = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTMENT_TYPE, element);
v.reset().parameter(CtsClearingApiConstants.PRESENTMENT_TYPE).value(presentmentType).ignoreIfNull().notExceedingLengthOf(10);
final String presentingBankCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTING_BANK_CODE,
element);
v.reset().parameter(CtsClearingApiConstants.PRESENTING_BANK_CODE).value(presentingBankCode).ignoreIfNull()
.notExceedingLengthOf(15);
final String presentingBankName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTING_BANK_NAME,
element);
v.reset().parameter(CtsClearingApiConstants.PRESENTING_BANK_NAME).value(presentingBankName).ignoreIfNull()
.notExceedingLengthOf(150);
final JsonArray items = this.fromApiJsonHelper.extractJsonArrayNamed(CtsClearingApiConstants.ITEMS, element);
if (items == null || items.size() == 0) {
v.reset().parameter(CtsClearingApiConstants.ITEMS).value(items)
.failWithCode("cannot.be.empty", "At least one clearing item must be supplied.");
} else {
final Locale locale = localeOrDefault(element);
for (int i = 0; i < items.size(); i++) {
validateItemElement(items.get(i), locale, v, i);
}
}
throwIfErrors(errors);
}
private void validateItemElement(final JsonElement itemElement, final Locale locale, final DataValidatorBuilder v, final int index) {
final String chequeNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CHEQUE_NUMBER, itemElement);
v.reset().parameter(CtsClearingApiConstants.ITEMS + "[" + index + "]." + CtsClearingApiConstants.CHEQUE_NUMBER)
.value(chequeNumber).notBlank().notExceedingLengthOf(25);
final java.math.BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalNamed(CtsClearingApiConstants.AMOUNT, itemElement,
locale);
v.reset().parameter(CtsClearingApiConstants.ITEMS + "[" + index + "]." + CtsClearingApiConstants.AMOUNT).value(amount).notNull()
.positiveAmount();
final String accountType = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.ACCOUNT_TYPE, itemElement);
v.reset().parameter(CtsClearingApiConstants.ITEMS + "[" + index + "]." + CtsClearingApiConstants.ACCOUNT_TYPE).value(accountType)
.notBlank().isOneOfTheseStringValues("savings", "current");
final Long accountId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.ACCOUNT_ID, itemElement);
v.reset().parameter(CtsClearingApiConstants.ITEMS + "[" + index + "]." + CtsClearingApiConstants.ACCOUNT_ID).value(accountId)
.notNull().longGreaterThanZero();
final String originalSerialNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.ORIGINAL_SERIAL_NUMBER,
itemElement);
v.reset().parameter(CtsClearingApiConstants.ITEMS + "[" + index + "]." + CtsClearingApiConstants.ORIGINAL_SERIAL_NUMBER)
.value(originalSerialNumber).ignoreIfNull().notExceedingLengthOf(20);
final String presentingBranchCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTING_BRANCH_CODE,
itemElement);
v.reset().parameter(CtsClearingApiConstants.ITEMS + "[" + index + "]." + CtsClearingApiConstants.PRESENTING_BRANCH_CODE)
.value(presentingBranchCode).ignoreIfNull().notExceedingLengthOf(20);
}
public void validateForPassItem(final String json) {
final JsonElement element = parse(json, ITEM_PASS_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(CtsClearingApiConstants.RESOURCE_INWARD_ITEM);
final String note = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.NOTE, element);
v.reset().parameter(CtsClearingApiConstants.NOTE).value(note).ignoreIfNull().notExceedingLengthOf(500);
throwIfErrors(errors);
}
public void validateForReturnItem(final String json) {
final JsonElement element = parse(json, ITEM_RETURN_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(CtsClearingApiConstants.RESOURCE_INWARD_ITEM);
final String returnReasonCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.RETURN_REASON_CODE, element);
v.reset().parameter(CtsClearingApiConstants.RETURN_REASON_CODE).value(returnReasonCode).notBlank().notExceedingLengthOf(10);
final String reason = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.REASON, element);
v.reset().parameter(CtsClearingApiConstants.REASON).value(reason).ignoreIfNull().notExceedingLengthOf(500);
throwIfErrors(errors);
}
public void validateForCreateOutward(final String json) {
final JsonElement element = parse(json, OUTWARD_CREATE_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(CtsClearingApiConstants.RESOURCE_OUTWARD_PRESENTMENT);
final String accountType = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DEPOSITING_ACCOUNT_TYPE, element);
v.reset().parameter(CtsClearingApiConstants.DEPOSITING_ACCOUNT_TYPE).value(accountType).notBlank()
.isOneOfTheseStringValues("savings", "current");
final Long accountId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.DEPOSITING_ACCOUNT_ID, element);
v.reset().parameter(CtsClearingApiConstants.DEPOSITING_ACCOUNT_ID).value(accountId).notNull().longGreaterThanZero();
final String chequeNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CHEQUE_NUMBER, element);
v.reset().parameter(CtsClearingApiConstants.CHEQUE_NUMBER).value(chequeNumber).notBlank().notExceedingLengthOf(25);
final Locale locale = localeOrDefault(element);
final java.math.BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalNamed(CtsClearingApiConstants.AMOUNT, element, locale);
v.reset().parameter(CtsClearingApiConstants.AMOUNT).value(amount).notNull().positiveAmount();
v.reset().parameter(CtsClearingApiConstants.DEPOSITED_DATE)
.value(this.fromApiJsonHelper.extractLocalDateNamed(CtsClearingApiConstants.DEPOSITED_DATE, element)).notNull();
final String drawerBankName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DRAWER_BANK_NAME, element);
v.reset().parameter(CtsClearingApiConstants.DRAWER_BANK_NAME).value(drawerBankName).ignoreIfNull().notExceedingLengthOf(150);
final String drawerIfsc = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DRAWER_IFSC, element);
v.reset().parameter(CtsClearingApiConstants.DRAWER_IFSC).value(drawerIfsc).ignoreIfNull().notExceedingLengthOf(15);
final String drawerBankMicr = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DRAWER_BANK_MICR, element);
v.reset().parameter(CtsClearingApiConstants.DRAWER_BANK_MICR).value(drawerBankMicr).ignoreIfNull().notExceedingLengthOf(20);
throwIfErrors(errors);
}
public void validateForDishonourOutward(final String json) {
final JsonElement element = parse(json, OUTWARD_DISHONOUR_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(CtsClearingApiConstants.RESOURCE_OUTWARD_PRESENTMENT);
final String reasonCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DISHONOUR_REASON_CODE, element);
v.reset().parameter(CtsClearingApiConstants.DISHONOUR_REASON_CODE).value(reasonCode).notBlank().notExceedingLengthOf(10);
throwIfErrors(errors);
}
private static final Set<String> POSITIVE_PAY_CREATE_PARAMS = new HashSet<>(Arrays.asList(
CtsClearingApiConstants.DEPOSITING_ACCOUNT_TYPE, CtsClearingApiConstants.ACCOUNT_TYPE, CtsClearingApiConstants.ACCOUNT_ID,
CtsClearingApiConstants.CHEQUE_NUMBER, CtsClearingApiConstants.INSTRUMENT_DATE, CtsClearingApiConstants.AMOUNT,
CtsClearingApiConstants.PAYEE_NAME, CtsClearingApiConstants.CHANNEL, CtsClearingApiConstants.LOCALE,
CtsClearingApiConstants.DATE_FORMAT));
private static final Set<String> POSITIVE_PAY_CANCEL_PARAMS = new HashSet<>(
Arrays.asList(CtsClearingApiConstants.REASON, CtsClearingApiConstants.LOCALE, CtsClearingApiConstants.DATE_FORMAT));
public void validateForCreatePositivePay(final String json) {
final JsonElement element = parse(json, POSITIVE_PAY_CREATE_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(CtsClearingApiConstants.RESOURCE_POSITIVE_PAY);
final String accountType = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.ACCOUNT_TYPE, element);
v.reset().parameter(CtsClearingApiConstants.ACCOUNT_TYPE).value(accountType).notBlank()
.isOneOfTheseStringValues("savings", "current");
final Long accountId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.ACCOUNT_ID, element);
v.reset().parameter(CtsClearingApiConstants.ACCOUNT_ID).value(accountId).notNull().longGreaterThanZero();
final String chequeNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CHEQUE_NUMBER, element);
v.reset().parameter(CtsClearingApiConstants.CHEQUE_NUMBER).value(chequeNumber).notBlank().notExceedingLengthOf(25);
final Locale locale = localeOrDefault(element);
final java.math.BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalNamed(CtsClearingApiConstants.AMOUNT, element, locale);
v.reset().parameter(CtsClearingApiConstants.AMOUNT).value(amount).notNull().positiveAmount();
throwIfErrors(errors);
}
public void validateForCancelPositivePay(final String json) {
final JsonElement element = parse(json, POSITIVE_PAY_CANCEL_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(CtsClearingApiConstants.RESOURCE_POSITIVE_PAY);
final String reason = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.REASON, element);
v.reset().parameter(CtsClearingApiConstants.REASON).value(reason).ignoreIfNull().notExceedingLengthOf(500);
throwIfErrors(errors);
}
/** SEND / SETTLE carry no body of their own — accept an empty/locale-only payload. */
public void validateNoBody(final String json) {
if (StringUtils.isBlank(json)) {
return;
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json,
new HashSet<>(Arrays.asList(CtsClearingApiConstants.LOCALE, CtsClearingApiConstants.DATE_FORMAT)));
}
private Locale localeOrDefault(final JsonElement element) {
final Locale locale = this.fromApiJsonHelper.extractLocaleParameter(element.getAsJsonObject());
return locale == null ? Locale.ENGLISH : locale;
}
private JsonElement parse(final String json, final Set<String> supportedParams) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParams);
return this.fromApiJsonHelper.parse(json);
}
private void throwIfErrors(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
}
@@ -0,0 +1,59 @@
/**
* 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.service;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.ctsclearing.data.InwardClearingBatchData;
import org.apache.fineract.portfolio.ctsclearing.data.InwardClearingItemData;
import org.apache.fineract.portfolio.ctsclearing.data.OutwardClearingBatchSummaryData;
import org.apache.fineract.portfolio.ctsclearing.data.OutwardClearingPresentmentData;
import org.apache.fineract.portfolio.ctsclearing.data.PositivePayRegistrationData;
public interface CtsClearingReadPlatformService {
Page<InwardClearingBatchData> retrieveAllBatches(String status, LocalDate fromSessionDate, LocalDate toSessionDate,
String batchReference, String presentingBankName, Integer offset, Integer limit);
InwardClearingBatchData retrieveBatch(Long id);
List<InwardClearingItemData> retrieveItemsByBatch(Long batchId);
InwardClearingItemData retrieveItem(Long id);
Page<OutwardClearingPresentmentData> retrieveAllOutward(String status, LocalDate fromDepositedDate, LocalDate toDepositedDate,
String chequeNumber, String drawerBankName, String payeeName, String batchReference, Integer offset, Integer limit);
OutwardClearingPresentmentData retrieveOutward(Long id);
/** One row per outward clearing session ("batch") — cheques grouped by the shared batchReference they were sent under. */
Page<OutwardClearingBatchSummaryData> retrieveOutwardBatches(LocalDate fromDate, LocalDate toDate, String batchReference,
Integer offset, Integer limit);
Page<PositivePayRegistrationData> retrievePositivePayRegistrations(Long accountId, String status, Integer offset, Integer limit);
PositivePayRegistrationData retrievePositivePayRegistration(Long id);
/** Returns the stored "mimeType|storagePath" for an inward item's front/back image, or null if none uploaded. */
String retrieveInwardItemImageStore(Long itemId, String side);
/** Returns the stored "mimeType|storagePath" for an outward presentment's front/back image, or null if none uploaded. */
String retrieveOutwardPresentmentImageStore(Long presentmentId, String side);
}
@@ -0,0 +1,525 @@
/**
* 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.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.ctsclearing.data.InwardClearingBatchData;
import org.apache.fineract.portfolio.ctsclearing.data.InwardClearingItemData;
import org.apache.fineract.portfolio.ctsclearing.data.OutwardClearingBatchSummaryData;
import org.apache.fineract.portfolio.ctsclearing.data.OutwardClearingPresentmentData;
import org.apache.fineract.portfolio.ctsclearing.data.PositivePayRegistrationData;
import org.apache.fineract.portfolio.ctsclearing.exception.InwardClearingBatchNotFoundException;
import org.apache.fineract.portfolio.ctsclearing.exception.InwardClearingItemNotFoundException;
import org.apache.fineract.portfolio.ctsclearing.exception.OutwardClearingPresentmentNotFoundException;
import org.apache.fineract.portfolio.ctsclearing.exception.PositivePayRegistrationNotFoundException;
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.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CtsClearingReadPlatformServiceImpl implements CtsClearingReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final SavingsAccountRepository savingsAccountRepository;
private final CurrentAccountRepository currentAccountRepository;
private final BatchMapper batchMapper = new BatchMapper();
private final ItemMapper itemMapper = new ItemMapper();
private final OutwardMapper outwardMapper = new OutwardMapper();
private final OutwardBatchSummaryMapper outwardBatchSummaryMapper = new OutwardBatchSummaryMapper();
private final PositivePayMapper positivePayMapper = new PositivePayMapper();
@Override
public Page<InwardClearingBatchData> retrieveAllBatches(final String status, final LocalDate fromSessionDate,
final LocalDate toSessionDate, final String batchReference, final String presentingBankName, final Integer offset,
final Integer limit) {
final StringBuilder where = new StringBuilder(" where 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
where.append(" and b.status = ?");
args.add(status.trim().toUpperCase());
}
if (fromSessionDate != null) {
where.append(" and b.session_date >= ?");
args.add(java.sql.Date.valueOf(fromSessionDate));
}
if (toSessionDate != null) {
where.append(" and b.session_date <= ?");
args.add(java.sql.Date.valueOf(toSessionDate));
}
if (StringUtils.isNotBlank(batchReference)) {
where.append(" and lower(b.batch_reference) like lower(?)");
args.add("%" + batchReference.trim() + "%");
}
if (StringUtils.isNotBlank(presentingBankName)) {
where.append(" and lower(b.presenting_bank_name) like lower(?)");
args.add("%" + presentingBankName.trim() + "%");
}
final String countSql = "select count(*) from (select " + this.batchMapper.schema() + where + ") x";
final Long total = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final StringBuilder pageSql = new StringBuilder("select ").append(this.batchMapper.schema()).append(where)
.append(" order by b.id desc limit ? offset ?");
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<InwardClearingBatchData> batches = this.jdbcTemplate.query(pageSql.toString(), this.batchMapper, pageArgs.toArray());
return new Page<>(batches, total == null ? 0 : total.intValue());
}
@Override
public InwardClearingBatchData retrieveBatch(final Long id) {
try {
final String sql = "select " + this.batchMapper.schema() + " where b.id = ?";
return this.jdbcTemplate.queryForObject(sql, this.batchMapper, id);
} catch (final EmptyResultDataAccessException e) {
throw new InwardClearingBatchNotFoundException(id);
}
}
@Override
public List<InwardClearingItemData> retrieveItemsByBatch(final Long batchId) {
final String sql = "select " + this.itemMapper.schema() + " where i.batch_id = ? order by i.id asc";
final List<InwardClearingItemData> items = this.jdbcTemplate.query(sql, this.itemMapper, batchId);
return items.stream().map(this::withAccountInfo).toList();
}
@Override
public InwardClearingItemData retrieveItem(final Long id) {
try {
final String sql = "select " + this.itemMapper.schema() + " where i.id = ?";
final InwardClearingItemData item = this.jdbcTemplate.queryForObject(sql, this.itemMapper, id);
return withAccountInfo(item);
} catch (final EmptyResultDataAccessException e) {
throw new InwardClearingItemNotFoundException(id);
}
}
@Override
public Page<OutwardClearingPresentmentData> retrieveAllOutward(final String status, final LocalDate fromDepositedDate,
final LocalDate toDepositedDate, final String chequeNumber, final String drawerBankName, final String payeeName,
final String batchReference, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" where 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
where.append(" and p.status = ?");
args.add(status.trim().toUpperCase());
}
if (fromDepositedDate != null) {
where.append(" and p.deposited_date >= ?");
args.add(java.sql.Date.valueOf(fromDepositedDate));
}
if (toDepositedDate != null) {
where.append(" and p.deposited_date <= ?");
args.add(java.sql.Date.valueOf(toDepositedDate));
}
if (StringUtils.isNotBlank(chequeNumber)) {
where.append(" and lower(p.cheque_number) like lower(?)");
args.add("%" + chequeNumber.trim() + "%");
}
if (StringUtils.isNotBlank(drawerBankName)) {
where.append(" and lower(p.drawer_bank_name) like lower(?)");
args.add("%" + drawerBankName.trim() + "%");
}
if (StringUtils.isNotBlank(payeeName)) {
where.append(" and lower(p.payee_name) like lower(?)");
args.add("%" + payeeName.trim() + "%");
}
if (StringUtils.isNotBlank(batchReference)) {
where.append(" and p.batch_reference = ?");
args.add(batchReference.trim());
}
final String countSql = "select count(*) from (select " + this.outwardMapper.schema() + where + ") x";
final Long total = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final StringBuilder pageSql = new StringBuilder("select ").append(this.outwardMapper.schema()).append(where)
.append(" order by p.id desc limit ? offset ?");
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<OutwardClearingPresentmentData> rows = this.jdbcTemplate.query(pageSql.toString(), this.outwardMapper,
pageArgs.toArray());
final List<OutwardClearingPresentmentData> withInfo = rows.stream().map(this::withAccountInfo).toList();
return new Page<>(withInfo, total == null ? 0 : total.intValue());
}
@Override
public OutwardClearingPresentmentData retrieveOutward(final Long id) {
try {
final String sql = "select " + this.outwardMapper.schema() + " where p.id = ?";
final OutwardClearingPresentmentData row = this.jdbcTemplate.queryForObject(sql, this.outwardMapper, id);
return withAccountInfo(row);
} catch (final EmptyResultDataAccessException e) {
throw new OutwardClearingPresentmentNotFoundException(id);
}
}
@Override
public Page<OutwardClearingBatchSummaryData> retrieveOutwardBatches(final LocalDate fromDate, final LocalDate toDate,
final String batchReference, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" where batch_reference is not null");
final List<Object> args = new ArrayList<>();
if (fromDate != null) {
where.append(" and date(sent_on) >= ?");
args.add(java.sql.Date.valueOf(fromDate));
}
if (toDate != null) {
where.append(" and date(sent_on) <= ?");
args.add(java.sql.Date.valueOf(toDate));
}
if (StringUtils.isNotBlank(batchReference)) {
where.append(" and lower(batch_reference) like lower(?)");
args.add("%" + batchReference.trim() + "%");
}
final String countSql = "select count(*) from (select batch_reference from m_outward_clearing_presentment " + where
+ " group by batch_reference) x";
final Long total = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final String groupedSchema = "batch_reference, min(sent_on) as session_started_on, max(sent_on) as session_ended_on, "
+ "count(*) as item_count, sum(amount) as total_amount, "
+ "sum(case when status = 'SENT_TO_CLEARING_HOUSE' then 1 else 0 end) as sent_count, "
+ "sum(case when status = 'SETTLED' then 1 else 0 end) as settled_count, "
+ "sum(case when status = 'DISHONOURED' then 1 else 0 end) as dishonoured_count "
+ "from m_outward_clearing_presentment" + where + " group by batch_reference";
final String pageSql = "select " + groupedSchema + " order by min(sent_on) desc limit ? offset ?";
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<OutwardClearingBatchSummaryData> rows = this.jdbcTemplate.query(pageSql, this.outwardBatchSummaryMapper,
pageArgs.toArray());
return new Page<>(rows, total == null ? 0 : total.intValue());
}
@Override
public Page<PositivePayRegistrationData> retrievePositivePayRegistrations(final Long accountId, final String status,
final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" where 1=1");
final List<Object> args = new ArrayList<>();
if (accountId != null) {
where.append(" and pp.account_id = ?");
args.add(accountId);
}
if (StringUtils.isNotBlank(status)) {
where.append(" and pp.status = ?");
args.add(status.trim().toUpperCase());
}
final String countSql = "select count(*) from (select " + this.positivePayMapper.schema() + where + ") x";
final Long total = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final StringBuilder pageSql = new StringBuilder("select ").append(this.positivePayMapper.schema()).append(where)
.append(" order by pp.id desc limit ? offset ?");
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<PositivePayRegistrationData> rows = this.jdbcTemplate.query(pageSql.toString(), this.positivePayMapper,
pageArgs.toArray());
final List<PositivePayRegistrationData> withInfo = rows.stream().map(this::withAccountInfo).toList();
return new Page<>(withInfo, total == null ? 0 : total.intValue());
}
@Override
public PositivePayRegistrationData retrievePositivePayRegistration(final Long id) {
try {
final String sql = "select " + this.positivePayMapper.schema() + " where pp.id = ?";
final PositivePayRegistrationData row = this.jdbcTemplate.queryForObject(sql, this.positivePayMapper, id);
return withAccountInfo(row);
} catch (final EmptyResultDataAccessException e) {
throw new PositivePayRegistrationNotFoundException(id);
}
}
@Override
public String retrieveInwardItemImageStore(final Long itemId, final String side) {
final String column = "front".equalsIgnoreCase(side) ? "front_image_store" : "back_image_store";
try {
return this.jdbcTemplate.queryForObject("select " + column + " from m_inward_clearing_item where id = ?", String.class,
itemId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
@Override
public String retrieveOutwardPresentmentImageStore(final Long presentmentId, final String side) {
final String column = "front".equalsIgnoreCase(side) ? "front_image_store" : "back_image_store";
try {
return this.jdbcTemplate.queryForObject("select " + column + " from m_outward_clearing_presentment where id = ?",
String.class, presentmentId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
// ============================= Account display (best effort) =============================
private InwardClearingItemData withAccountInfo(final InwardClearingItemData item) {
final AccountDisplay display = resolveAccountDisplay(item.getAccountType(), item.getAccountId());
return InwardClearingItemData.builder().id(item.getId()).batchId(item.getBatchId()).chequeLeafId(item.getChequeLeafId())
.accountType(item.getAccountType()).accountId(item.getAccountId()).accountNo(display.accountNo)
.clientName(display.clientName).chequeNumber(item.getChequeNumber()).micrCode(item.getMicrCode()).ifsc(item.getIfsc())
.payeeName(item.getPayeeName()).amount(item.getAmount()).amountInWords(item.getAmountInWords())
.instrumentDate(item.getInstrumentDate()).presentedDate(item.getPresentedDate())
.frontImageRef(item.getFrontImageRef()).backImageRef(item.getBackImageRef()).iqaStatus(item.getIqaStatus())
.iqaDetail(item.getIqaDetail())
.accountCheckPassed(item.getAccountCheckPassed()).balanceCheckPassed(item.getBalanceCheckPassed())
.signatureOk(item.getSignatureOk()).duplicateCheckPassed(item.getDuplicateCheckPassed())
.stopPaymentCheckPassed(item.getStopPaymentCheckPassed()).amountWordsFiguresMatch(item.getAmountWordsFiguresMatch())
.staleOrPostdated(item.getStaleOrPostdated()).positivePayCheck(item.getPositivePayCheck())
.validationDetail(item.getValidationDetail()).decision(item.getDecision())
.returnReasonCode(item.getReturnReasonCode()).decisionReason(item.getDecisionReason()).decisionedBy(item.getDecisionedBy())
.decisionedOn(item.getDecisionedOn()).itemStatus(item.getItemStatus()).holdPlaced(item.getHoldPlaced())
.holdAmount(item.getHoldAmount()).settlementBatchId(item.getSettlementBatchId())
.originalSerialNumber(item.getOriginalSerialNumber()).presentingBranchCode(item.getPresentingBranchCode())
.returnDeadline(item.getReturnDeadline()).ctsReferenceNumber(item.getCtsReferenceNumber())
.instrumentStopPaymentFlag(item.getInstrumentStopPaymentFlag()).depositDate(item.getDepositDate()).build();
}
private OutwardClearingPresentmentData withAccountInfo(final OutwardClearingPresentmentData row) {
final AccountDisplay display = resolveAccountDisplay(row.getDepositingAccountType(), row.getDepositingAccountId());
return OutwardClearingPresentmentData.builder().id(row.getId()).depositingAccountType(row.getDepositingAccountType())
.depositingAccountId(row.getDepositingAccountId()).accountNo(display.accountNo).clientName(display.clientName)
.drawerBankMicr(row.getDrawerBankMicr()).drawerIfsc(row.getDrawerIfsc()).drawerBankName(row.getDrawerBankName())
.chequeNumber(row.getChequeNumber()).amount(row.getAmount()).payeeName(row.getPayeeName())
.depositedDate(row.getDepositedDate()).instrumentDate(row.getInstrumentDate())
.stopPaymentFlag(row.getStopPaymentFlag()).frontImageRef(row.getFrontImageRef()).backImageRef(row.getBackImageRef())
.iqaStatus(row.getIqaStatus()).iqaDetail(row.getIqaDetail()).drawerAccountNumber(row.getDrawerAccountNumber())
.gridZoneCode(row.getGridZoneCode()).gridZoneName(row.getGridZoneName()).status(row.getStatus())
.batchReference(row.getBatchReference()).ctsFileJson(row.getCtsFileJson())
.npciResponseJson(row.getNpciResponseJson())
.sentOn(row.getSentOn())
.dishonourReasonCode(row.getDishonourReasonCode())
.officeId(row.getOfficeId()).settledOn(row.getSettledOn()).createdBy(row.getCreatedBy()).createdOn(row.getCreatedOn())
.build();
}
private PositivePayRegistrationData withAccountInfo(final PositivePayRegistrationData row) {
final AccountDisplay display = resolveAccountDisplay(row.getAccountType(), row.getAccountId());
return PositivePayRegistrationData.builder().id(row.getId()).accountType(row.getAccountType()).accountId(row.getAccountId())
.accountNo(display.accountNo).clientName(display.clientName).chequeNumber(row.getChequeNumber())
.instrumentDate(row.getInstrumentDate()).amount(row.getAmount()).payeeName(row.getPayeeName()).channel(row.getChannel())
.status(row.getStatus()).registeredBy(row.getRegisteredBy()).registeredOn(row.getRegisteredOn())
.cancelledReason(row.getCancelledReason()).build();
}
private AccountDisplay resolveAccountDisplay(final String accountType, final Long accountId) {
if (accountType == null || accountId == null) {
return new AccountDisplay(null, null);
}
if ("SAVINGS".equalsIgnoreCase(accountType)) {
final SavingsAccount account = this.savingsAccountRepository.findById(accountId).orElse(null);
if (account == null) {
return new AccountDisplay(null, null);
}
final String clientName = account.getClient() == null ? null : account.getClient().getDisplayName();
return new AccountDisplay(account.getAccountNumber(), clientName);
}
if ("CURRENT".equalsIgnoreCase(accountType)) {
final CurrentAccount account = this.currentAccountRepository.findById(accountId).orElse(null);
if (account == null) {
return new AccountDisplay(null, null);
}
final String clientName = account.getClient() == null ? null : account.getClient().getDisplayName();
return new AccountDisplay(account.getAccountNo(), clientName);
}
return new AccountDisplay(null, null);
}
private static final class AccountDisplay {
private final String accountNo;
private final String clientName;
private AccountDisplay(final String accountNo, final String clientName) {
this.accountNo = accountNo;
this.clientName = clientName;
}
}
// ============================= Row mappers =============================
private static final class BatchMapper implements RowMapper<InwardClearingBatchData> {
String schema() {
return " b.id as id, b.batch_reference as batch_reference, b.session_date as session_date, "
+ "b.presentment_type as presentment_type, b.source_file as source_file, b.total_item_count as total_item_count, "
+ "b.total_amount as total_amount, b.status as status, b.office_id as office_id, o.name as office_name, "
+ "b.received_by as received_by, b.received_on as received_on, b.validated_on as validated_on, "
+ "b.decisioned_on as decisioned_on, b.settled_on as settled_on, b.created_on as created_on, "
+ "b.presenting_bank_code as presenting_bank_code, b.presenting_bank_name as presenting_bank_name, "
+ "b.return_tat_deadline as return_tat_deadline "
+ "from m_inward_clearing_batch b left join m_office o on o.id = b.office_id";
}
@Override
public InwardClearingBatchData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return InwardClearingBatchData.builder().id(rs.getLong("id")).batchReference(rs.getString("batch_reference"))
.sessionDate(rs.getObject("session_date", LocalDate.class)).presentmentType(rs.getString("presentment_type"))
.sourceFile(rs.getString("source_file")).totalItemCount((Integer) rs.getObject("total_item_count"))
.totalAmount(rs.getBigDecimal("total_amount")).status(rs.getString("status")).officeId((Long) rs.getObject("office_id"))
.officeName(rs.getString("office_name")).receivedBy((Long) rs.getObject("received_by"))
.receivedOn(rs.getObject("received_on", OffsetDateTime.class)).validatedOn(rs.getObject("validated_on", OffsetDateTime.class))
.decisionedOn(rs.getObject("decisioned_on", OffsetDateTime.class)).settledOn(rs.getObject("settled_on", OffsetDateTime.class))
.createdOn(rs.getObject("created_on", OffsetDateTime.class))
.presentingBankCode(rs.getString("presenting_bank_code")).presentingBankName(rs.getString("presenting_bank_name"))
.returnTatDeadline(rs.getObject("return_tat_deadline", LocalDate.class)).build();
}
}
private static final class ItemMapper implements RowMapper<InwardClearingItemData> {
String schema() {
return " i.id as id, i.batch_id as batch_id, i.cheque_leaf_id as cheque_leaf_id, i.account_type as account_type, "
+ "i.account_id as account_id, i.cheque_number as cheque_number, i.micr_code as micr_code, i.ifsc as ifsc, "
+ "i.payee_name as payee_name, i.amount as amount, i.amount_in_words as amount_in_words, "
+ "i.instrument_date as instrument_date, i.presented_date as presented_date, "
+ "i.front_image_ref as front_image_ref, i.back_image_ref as back_image_ref, i.iqa_status as iqa_status, "
+ "i.iqa_detail as iqa_detail, "
+ "i.account_check_passed as account_check_passed, i.balance_check_passed as balance_check_passed, "
+ "i.signature_ok as signature_ok, i.duplicate_check_passed as duplicate_check_passed, "
+ "i.stop_payment_check_passed as stop_payment_check_passed, i.amount_words_figures_match as amount_words_figures_match, "
+ "i.stale_or_postdated as stale_or_postdated, i.positive_pay_check as positive_pay_check, "
+ "i.validation_detail as validation_detail, i.decision as decision, "
+ "i.return_reason_code as return_reason_code, i.decision_reason as decision_reason, i.decisioned_by as decisioned_by, "
+ "i.decisioned_on as decisioned_on, i.item_status as item_status, i.hold_placed as hold_placed, "
+ "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 "
+ "from m_inward_clearing_item i";
}
@Override
public InwardClearingItemData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return InwardClearingItemData.builder().id(rs.getLong("id")).batchId(rs.getLong("batch_id"))
.chequeLeafId((Long) rs.getObject("cheque_leaf_id")).accountType(rs.getString("account_type"))
.accountId((Long) rs.getObject("account_id")).chequeNumber(rs.getString("cheque_number"))
.micrCode(rs.getString("micr_code")).ifsc(rs.getString("ifsc")).payeeName(rs.getString("payee_name"))
.amount(rs.getBigDecimal("amount")).amountInWords(rs.getString("amount_in_words"))
.instrumentDate(rs.getObject("instrument_date", LocalDate.class)).presentedDate(rs.getObject("presented_date", LocalDate.class))
.frontImageRef(rs.getString("front_image_ref")).backImageRef(rs.getString("back_image_ref"))
.iqaStatus(rs.getString("iqa_status")).iqaDetail(rs.getString("iqa_detail"))
.accountCheckPassed((Boolean) rs.getObject("account_check_passed"))
.balanceCheckPassed((Boolean) rs.getObject("balance_check_passed")).signatureOk((Boolean) rs.getObject("signature_ok"))
.duplicateCheckPassed((Boolean) rs.getObject("duplicate_check_passed"))
.stopPaymentCheckPassed((Boolean) rs.getObject("stop_payment_check_passed"))
.amountWordsFiguresMatch((Boolean) rs.getObject("amount_words_figures_match"))
.staleOrPostdated((Boolean) rs.getObject("stale_or_postdated"))
.positivePayCheck((Boolean) rs.getObject("positive_pay_check")).validationDetail(rs.getString("validation_detail"))
.decision(rs.getString("decision")).returnReasonCode(rs.getString("return_reason_code"))
.decisionReason(rs.getString("decision_reason")).decisionedBy((Long) rs.getObject("decisioned_by"))
.decisionedOn(rs.getObject("decisioned_on", OffsetDateTime.class)).itemStatus(rs.getString("item_status"))
.holdPlaced((Boolean) rs.getObject("hold_placed")).holdAmount(rs.getBigDecimal("hold_amount"))
.settlementBatchId((Long) rs.getObject("settlement_batch_id"))
.originalSerialNumber(rs.getString("original_serial_number"))
.presentingBranchCode(rs.getString("presenting_branch_code"))
.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();
}
}
private static final class OutwardMapper implements RowMapper<OutwardClearingPresentmentData> {
String schema() {
return " p.id as id, p.depositing_account_type as depositing_account_type, p.depositing_account_id as depositing_account_id, "
+ "p.drawer_bank_micr as drawer_bank_micr, p.drawer_ifsc as drawer_ifsc, p.drawer_bank_name as drawer_bank_name, "
+ "p.cheque_number as cheque_number, p.amount as amount, p.payee_name as payee_name, "
+ "p.deposited_date as deposited_date, p.instrument_date as instrument_date, "
+ "p.stop_payment_flag as stop_payment_flag, p.front_image_ref as front_image_ref, "
+ "p.back_image_ref as back_image_ref, p.iqa_status as iqa_status, p.iqa_detail as iqa_detail, "
+ "p.drawer_account_number as drawer_account_number, p.grid_zone_code as grid_zone_code, "
+ "p.grid_zone_name as grid_zone_name, p.status as status, "
+ "p.batch_reference as batch_reference, p.cts_file_json as cts_file_json, "
+ "p.npci_response_json as npci_response_json, "
+ "p.sent_on as sent_on, p.dishonour_reason_code as dishonour_reason_code, "
+ "p.office_id as office_id, p.settled_on as settled_on, p.created_by as created_by, p.created_on as created_on "
+ "from m_outward_clearing_presentment p";
}
@Override
public OutwardClearingPresentmentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return OutwardClearingPresentmentData.builder().id(rs.getLong("id"))
.depositingAccountType(rs.getString("depositing_account_type"))
.depositingAccountId((Long) rs.getObject("depositing_account_id")).drawerBankMicr(rs.getString("drawer_bank_micr"))
.drawerIfsc(rs.getString("drawer_ifsc")).drawerBankName(rs.getString("drawer_bank_name"))
.chequeNumber(rs.getString("cheque_number")).amount(rs.getBigDecimal("amount")).payeeName(rs.getString("payee_name"))
.depositedDate(rs.getObject("deposited_date", LocalDate.class))
.instrumentDate(rs.getObject("instrument_date", LocalDate.class))
.stopPaymentFlag((Boolean) rs.getObject("stop_payment_flag")).frontImageRef(rs.getString("front_image_ref"))
.backImageRef(rs.getString("back_image_ref")).iqaStatus(rs.getString("iqa_status"))
.iqaDetail(rs.getString("iqa_detail")).drawerAccountNumber(rs.getString("drawer_account_number"))
.gridZoneCode(rs.getString("grid_zone_code")).gridZoneName(rs.getString("grid_zone_name"))
.status(rs.getString("status"))
.batchReference(rs.getString("batch_reference")).ctsFileJson(rs.getString("cts_file_json"))
.npciResponseJson(rs.getString("npci_response_json"))
.sentOn(rs.getObject("sent_on", OffsetDateTime.class))
.dishonourReasonCode(rs.getString("dishonour_reason_code")).officeId((Long) rs.getObject("office_id"))
.settledOn(rs.getObject("settled_on", OffsetDateTime.class)).createdBy((Long) rs.getObject("created_by"))
.createdOn(rs.getObject("created_on", OffsetDateTime.class)).build();
}
}
private static final class OutwardBatchSummaryMapper implements RowMapper<OutwardClearingBatchSummaryData> {
@Override
public OutwardClearingBatchSummaryData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return OutwardClearingBatchSummaryData.builder().batchReference(rs.getString("batch_reference"))
.sessionStartedOn(rs.getObject("session_started_on", OffsetDateTime.class))
.sessionEndedOn(rs.getObject("session_ended_on", OffsetDateTime.class)).itemCount(rs.getInt("item_count"))
.totalAmount(rs.getBigDecimal("total_amount")).sentCount(rs.getInt("sent_count"))
.settledCount(rs.getInt("settled_count")).dishonouredCount(rs.getInt("dishonoured_count")).build();
}
}
private static final class PositivePayMapper implements RowMapper<PositivePayRegistrationData> {
String schema() {
return " pp.id as id, pp.account_type as account_type, pp.account_id as account_id, pp.cheque_number as cheque_number, "
+ "pp.instrument_date as instrument_date, pp.amount as amount, pp.payee_name as payee_name, pp.channel as channel, "
+ "pp.status as status, pp.registered_by as registered_by, pp.registered_on as registered_on, "
+ "pp.cancelled_reason as cancelled_reason from m_positive_pay_registration pp";
}
@Override
public PositivePayRegistrationData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return PositivePayRegistrationData.builder().id(rs.getLong("id")).accountType(rs.getString("account_type"))
.accountId((Long) rs.getObject("account_id")).chequeNumber(rs.getString("cheque_number"))
.instrumentDate(rs.getObject("instrument_date", LocalDate.class)).amount(rs.getBigDecimal("amount"))
.payeeName(rs.getString("payee_name")).channel(rs.getString("channel")).status(rs.getString("status"))
.registeredBy((Long) rs.getObject("registered_by")).registeredOn(rs.getObject("registered_on", OffsetDateTime.class))
.cancelledReason(rs.getString("cancelled_reason")).build();
}
}
}
@@ -0,0 +1,58 @@
/**
* 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.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface CtsClearingWritePlatformService {
CommandProcessingResult receiveBatch(JsonCommand command);
CommandProcessingResult validateBatch(Long batchId, JsonCommand command);
CommandProcessingResult passItem(Long itemId, JsonCommand command);
CommandProcessingResult returnItem(Long itemId, JsonCommand command);
CommandProcessingResult createOutward(JsonCommand command);
CommandProcessingResult sendOutward(Long presentmentId, JsonCommand command);
/** Bundles every still-CAPTURED outward presentment into ONE CTS batch file and sends it to NPCI. */
CommandProcessingResult sendPendingOutwardBatch(JsonCommand command);
CommandProcessingResult settleOutward(Long presentmentId, JsonCommand command);
CommandProcessingResult dishonourOutward(Long presentmentId, JsonCommand command);
CommandProcessingResult createPositivePay(JsonCommand command);
CommandProcessingResult cancelPositivePay(Long registrationId, JsonCommand command);
/**
* Records where a real uploaded front/back image for an inward item lives (a plain field update, not a
* command — no financial effect, so it bypasses the audited command pipeline like the shared election
* module's own image-upload endpoint does).
*/
void recordInwardItemImage(Long itemId, String side, String mimeTypeAndPath);
/** Same as {@link #recordInwardItemImage} for an outward presentment. */
void recordOutwardPresentmentImage(Long presentmentId, String side, String mimeTypeAndPath);
}
@@ -0,0 +1,848 @@
/**
* 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.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.chequebook.domain.ChequeLeaf;
import org.apache.fineract.portfolio.chequebook.domain.ChequeLeafRepository;
import org.apache.fineract.portfolio.chequebook.domain.ChequeLeafStatus;
import com.google.gson.JsonObject;
import org.apache.fineract.portfolio.clearing.domain.ClearingReturnReasonCode;
import org.apache.fineract.portfolio.clearing.domain.ClearingReturnReasonCodeRepository;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.portfolio.clearing.npci.client.NpciClearingClient;
import org.apache.fineract.portfolio.clearing.service.ClearingGlAccountResolverService;
import org.apache.fineract.portfolio.clearing.service.ClearingJournalService;
import org.apache.fineract.portfolio.clearing.service.ClearingNotificationService;
import org.apache.fineract.portfolio.ctsclearing.api.CtsClearingApiConstants;
import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingBatch;
import org.apache.fineract.portfolio.ctsclearing.domain.InwardClearingBatchRepository;
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.ctsclearing.domain.InwardClearingItemRepositoryWrapper;
import org.apache.fineract.portfolio.ctsclearing.domain.OutwardClearingPresentment;
import org.apache.fineract.portfolio.ctsclearing.domain.OutwardClearingPresentmentRepository;
import org.apache.fineract.portfolio.ctsclearing.domain.OutwardClearingPresentmentRepositoryWrapper;
import org.apache.fineract.portfolio.ctsclearing.domain.OutwardClearingPresentmentStatus;
import org.apache.fineract.portfolio.ctsclearing.domain.PositivePayRegistration;
import org.apache.fineract.portfolio.ctsclearing.domain.PositivePayRegistrationRepositoryWrapper;
import org.apache.fineract.portfolio.ctsclearing.serialization.CtsClearingCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepositoryWrapper;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionSummaryWrapper;
import org.apache.fineract.portfolio.savings.domain.SavingsHelper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Write-side of the CTS inward/outward clearing module. Mirrors the JsonCommand + CommandProcessingResultBuilder
* conventions used by {@code ChequeBookWritePlatformServiceImpl}, and reuses the shared clearing services for GL
* resolution, balanced journal posting and customer notifications. The customer-account debit on a passed item
* follows {@code ChequeBookIssuanceChargeService}'s direct-ledger pattern (a transaction row + balance-field
* update, no standard withdrawal API).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlatformService {
private static final String SAVINGS = "SAVINGS";
private static final String CTS_INWARD_SUSPENSE = "CTS_INWARD_SUSPENSE";
private final PlatformSecurityContext context;
private final CtsClearingCommandFromApiJsonDeserializer validator;
private final FromJsonHelper fromApiJsonHelper;
private final InwardClearingBatchRepository batchRepository;
private final InwardClearingBatchRepositoryWrapper batchRepositoryWrapper;
private final InwardClearingItemRepository itemRepository;
private final InwardClearingItemRepositoryWrapper itemRepositoryWrapper;
private final OutwardClearingPresentmentRepository outwardRepository;
private final OutwardClearingPresentmentRepositoryWrapper outwardRepositoryWrapper;
private final PositivePayRegistrationRepositoryWrapper positivePayRepositoryWrapper;
private final InwardClearingValidationService validationService;
private final ClearingGlAccountResolverService glResolver;
private final ClearingJournalService clearingJournalService;
private final ClearingNotificationService notificationService;
private final ClearingReturnReasonCodeRepository returnReasonCodeRepository;
private final ContentStoreService contentStoreService;
private final NpciClearingClient npciClearingClient;
private final ChequeLeafRepository chequeLeafRepository;
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
private final CurrentAccountRepositoryWrapper currentAccountRepositoryWrapper;
private final CurrentAccountRepository currentAccountRepository;
private final SavingsAccountTransactionRepository savingsAccountTransactionRepository;
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
private final SavingsHelper savingsHelper;
// ============================= Inward batch =============================
@Transactional
@Override
public CommandProcessingResult receiveBatch(final JsonCommand command) {
this.validator.validateForReceiveBatch(command.json());
final JsonElement element = command.parsedJson();
final String batchReference = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.BATCH_REFERENCE, element);
if (this.batchRepository.existsByBatchReferenceIgnoreCase(batchReference)) {
throw new GeneralPlatformDomainRuleException("error.msg.inward.clearing.batch.duplicate.reference",
"An inward clearing batch with reference " + batchReference + " already exists.", batchReference);
}
final LocalDate sessionDate = this.fromApiJsonHelper.extractLocalDateNamed(CtsClearingApiConstants.SESSION_DATE, element);
final String presentmentType = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTMENT_TYPE, element);
final String sourceFile = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.SOURCE_FILE, element);
final String presentingBankCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTING_BANK_CODE,
element);
final String presentingBankName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTING_BANK_NAME,
element);
final Long officeId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.OFFICE_ID, element);
final Long receivedBy = this.context.authenticatedUser().getId();
final InwardClearingBatch batch = InwardClearingBatch.receive(batchReference, sessionDate, presentmentType, sourceFile,
presentingBankCode, presentingBankName, officeId, receivedBy);
final InwardClearingBatch savedBatch = this.batchRepositoryWrapper.saveAndFlush(batch);
final Locale locale = localeOrDefault(element);
final String dateFormat = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DATE_FORMAT, element);
final JsonArray items = this.fromApiJsonHelper.extractJsonArrayNamed(CtsClearingApiConstants.ITEMS, element);
// RBI CTS return TAT: T+1 from the clearing session date, shared by every item received in this batch
final LocalDate returnDeadline = sessionDate == null ? null : sessionDate.plusDays(1);
final List<InwardClearingItem> toSave = new ArrayList<>();
BigDecimal totalAmount = BigDecimal.ZERO;
for (int i = 0; i < items.size(); i++) {
final JsonElement itemEl = items.get(i);
final String chequeNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CHEQUE_NUMBER, itemEl);
final String micrCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.MICR_CODE, itemEl);
final String ifsc = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.IFSC, itemEl);
final String payeeName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PAYEE_NAME, itemEl);
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalNamed(CtsClearingApiConstants.AMOUNT, itemEl, locale);
final String amountInWords = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.AMOUNT_IN_WORDS, itemEl);
final LocalDate instrumentDate = extractItemDate(CtsClearingApiConstants.INSTRUMENT_DATE, itemEl, dateFormat, locale);
final LocalDate presentedDate = extractItemDate(CtsClearingApiConstants.PRESENTED_DATE, itemEl, dateFormat, locale);
final String accountType = upperOrNull(this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.ACCOUNT_TYPE, itemEl));
final Long accountId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.ACCOUNT_ID, itemEl);
final Long chequeLeafId = resolveLeafId(chequeNumber);
final InwardClearingItem item = InwardClearingItem.receive(savedBatch, chequeLeafId, accountType, accountId, chequeNumber,
micrCode, ifsc, payeeName, amount, amountInWords, instrumentDate, presentedDate);
// scanned cheque images + IQA outcome received with the item (spec section 5)
item.captureImages(this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.FRONT_IMAGE_REF, itemEl),
this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.BACK_IMAGE_REF, itemEl),
this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.IQA_STATUS, itemEl),
this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.IQA_DETAIL, itemEl));
item.recordInwardMeta(this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.ORIGINAL_SERIAL_NUMBER, itemEl),
this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PRESENTING_BRANCH_CODE, itemEl), returnDeadline);
item.recordSourceDetails(this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CTS_REFERENCE_NUMBER, itemEl),
this.fromApiJsonHelper.extractBooleanNamed(CtsClearingApiConstants.STOP_PAYMENT_FLAG, itemEl),
extractItemDate(CtsClearingApiConstants.DEPOSIT_DATE, itemEl, dateFormat, locale));
toSave.add(item);
totalAmount = totalAmount.add(amount == null ? BigDecimal.ZERO : amount);
}
this.itemRepository.saveAll(toSave);
savedBatch.setTotals(toSave.size(), totalAmount);
this.batchRepositoryWrapper.saveAndFlush(savedBatch);
log.info("Received inward clearing batch {} ({}) with {} items totalling {}", savedBatch.getId(), batchReference, toSave.size(),
totalAmount);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(savedBatch.getId()).build();
}
@Transactional
@Override
public CommandProcessingResult validateBatch(final Long batchId, final JsonCommand command) {
final Long id = this.validationService.validate(batchId);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(id).build();
}
// ============================= Inward item decisions =============================
@Transactional
@Override
public CommandProcessingResult passItem(final Long itemId, final JsonCommand command) {
this.validator.validateForPassItem(command.json());
final InwardClearingItem item = this.itemRepositoryWrapper.findOneWithNotFoundDetection(itemId);
assertDecidable(item);
final Long userId = this.context.authenticatedUser().getId();
final String note = command.stringValueOfParameterNamedAllowingNull(CtsClearingApiConstants.NOTE);
final BigDecimal amount = item.getAmount() == null ? BigDecimal.ZERO : item.getAmount();
final LocalDate today = DateUtils.getBusinessLocalDate();
// debit the drawn-on customer account for the cheque amount, consuming any hold placed at validation
final AccountDebit debit = debitCustomerAccount(item, amount, today);
// move the money DR customer-control-GL / CR CTS inward suspense (best effort — skipped if GL unmapped)
final Long controlGlId = this.glResolver.resolveControlGlAccountId(item.getAccountType(), item.getAccountId());
final Long suspenseGlId = this.glResolver.resolveClearingGlAccountId(CTS_INWARD_SUSPENSE);
if (controlGlId == null || suspenseGlId == null) {
log.warn("Inward clearing item {} passed but GL posting skipped - missing control GL ({}) or suspense GL ({}).", itemId,
controlGlId, suspenseGlId);
} else {
this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount,
"CTS inward clearing pass - item #" + itemId, item.getAccountId());
}
markLeaf(item, true, "CTS cleared", null, userId);
item.pass(note, userId);
item.clearHold();
this.itemRepositoryWrapper.saveAndFlush(item);
maybeMarkBatchDecisioned(item.getBatch());
log.info("Passed inward clearing item {} for {} account {} amount {}", itemId, item.getAccountType(), item.getAccountId(), amount);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(itemId).build();
}
@Transactional
@Override
public CommandProcessingResult returnItem(final Long itemId, final JsonCommand command) {
this.validator.validateForReturnItem(command.json());
final InwardClearingItem item = this.itemRepositoryWrapper.findOneWithNotFoundDetection(itemId);
assertDecidable(item);
final Long userId = this.context.authenticatedUser().getId();
final String returnReasonCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.RETURN_REASON_CODE,
command.parsedJson());
final ClearingReturnReasonCode reasonCode = this.returnReasonCodeRepository.findByCodeIgnoreCase(returnReasonCode)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.clearing.return.reason.code.invalid",
"Clearing return reason code " + returnReasonCode + " is not configured.", returnReasonCode));
final String reason = command.stringValueOfParameterNamedAllowingNull(CtsClearingApiConstants.REASON);
final String effectiveReason = reason != null && !reason.isBlank() ? reason : reasonCode.getDescription();
// a return moves no money — just release any hold reserved at validation
releaseHoldOnly(item);
markLeaf(item, false, null, effectiveReason, userId);
item.returnItem(returnReasonCode, reason, userId);
item.clearHold();
this.itemRepositoryWrapper.saveAndFlush(item);
final String recipient = String.valueOf(item.getAccountId());
final String message = "Cheque " + item.getChequeNumber() + " for " + item.getAmount() + " returned unpaid. Reason: "
+ effectiveReason;
this.notificationService.notifyCustomer("SMS", recipient, "CTS_RETURN", "CTS", "INWARD_CLEARING_ITEM", itemId, message);
maybeMarkBatchDecisioned(item.getBatch());
log.info("Returned inward clearing item {} (reason {})", itemId, returnReasonCode);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(itemId).build();
}
// ============================= Outward presentments =============================
@Transactional
@Override
public CommandProcessingResult createOutward(final JsonCommand command) {
this.validator.validateForCreateOutward(command.json());
final JsonElement element = command.parsedJson();
final Locale locale = localeOrDefault(element);
final String accountType = upperOrNull(
this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DEPOSITING_ACCOUNT_TYPE, element));
final Long accountId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.DEPOSITING_ACCOUNT_ID, element);
final String drawerBankMicr = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DRAWER_BANK_MICR, element);
final String drawerIfsc = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DRAWER_IFSC, element);
final String drawerBankName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DRAWER_BANK_NAME, element);
final String chequeNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CHEQUE_NUMBER, element);
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalNamed(CtsClearingApiConstants.AMOUNT, element, locale);
final String payeeName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PAYEE_NAME, element);
final LocalDate depositedDate = this.fromApiJsonHelper.extractLocalDateNamed(CtsClearingApiConstants.DEPOSITED_DATE, element);
// Instrument date is not user-entered — a cheque is always captured the day it's presented over the counter.
final LocalDate instrumentDate = DateUtils.getBusinessLocalDate();
final Long createdBy = this.context.authenticatedUser().getId();
// captured on the UI form — not hardcoded — so the CTS outward file reflects what was actually keyed in
final Boolean stopPaymentFlag = this.fromApiJsonHelper.extractBooleanNamed(CtsClearingApiConstants.STOP_PAYMENT_FLAG, element);
final String frontImageRef = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.FRONT_IMAGE_REF, element);
final String backImageRef = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.BACK_IMAGE_REF, element);
final String iqaStatus = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.IQA_STATUS, element);
final String iqaDetail = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.IQA_DETAIL, element);
final String drawerAccountNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DRAWER_ACCOUNT_NUMBER,
element);
final String gridZoneCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.GRID_ZONE_CODE, element);
final String gridZoneName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.GRID_ZONE_NAME, element);
final Long officeId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.OFFICE_ID, element);
final OutwardClearingPresentment presentment = OutwardClearingPresentment.capture(accountType, accountId, drawerBankMicr,
drawerIfsc, drawerBankName, chequeNumber, amount, payeeName, depositedDate, instrumentDate, officeId, createdBy,
stopPaymentFlag, frontImageRef, backImageRef, iqaStatus, iqaDetail, drawerAccountNumber, gridZoneCode, gridZoneName);
final OutwardClearingPresentment saved = this.outwardRepositoryWrapper.saveAndFlush(presentment);
log.info("Captured outward clearing presentment {} (cheque {} amount {})", saved.getId(), chequeNumber, amount);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()).build();
}
/**
* Sends an outward cheque to the clearing house: bundles it into the real CTS-2010 file (batchHeader +
* instrument + batchFooter, images embedded), submits it to the real, separate NPCI clearing-house
* service (encrypted with NPCI's public key, signed with this bank's own private key — see
* {@link NpciClearingClient}), and marks the presentment SENT_TO_CLEARING_HOUSE only once NPCI has
* actually accepted it. If NPCI is unreachable or rejects the submission, this throws and the whole
* transaction rolls back — nothing is ever marked "sent" without a real transmission.
*/
@Transactional
@Override
public CommandProcessingResult sendOutward(final Long presentmentId, final JsonCommand command) {
this.validator.validateNoBody(command.json());
final OutwardClearingPresentment presentment = this.outwardRepositoryWrapper.findOneWithNotFoundDetection(presentmentId);
assertImagesUploaded(presentment);
sendOutwardBatchInternal(java.util.List.of(presentment), "CTS-OUT-" + DateUtils.getBusinessLocalDate() + "-" + presentmentId);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(presentmentId).build();
}
/** A cheque cannot go to NPCI without a real front+back image — the CTS file has nothing to embed otherwise. */
private void assertImagesUploaded(final OutwardClearingPresentment p) {
if (!hasImage(p.getFrontImageStore()) || !hasImage(p.getBackImageStore())) {
throw new GeneralPlatformDomainRuleException("error.msg.outward.clearing.presentment.images.missing",
"Outward clearing presentment " + p.getId() + " cannot be sent to NPCI without both front and back cheque images.",
p.getId());
}
}
private boolean hasImage(final String store) {
return store != null && !store.isBlank() && store.contains("|");
}
/**
* Sends every still-CAPTURED outward presentment to NPCI as ONE combined CTS-2010 batch file (one
* batchHeader shared by all instruments), instead of one file per cheque. This is what a real bank's
* clearing-session batch job does — bundle everything presented since the last session into a single
* outward file. Intended to be called either from the manual "Send Pending Batch" action or from the
* (currently disabled) scheduled clearing-session job. A no-op (no exception) when nothing is pending, so
* a scheduled run with nothing to send is not treated as a failure.
*/
@Transactional
@Override
public CommandProcessingResult sendPendingOutwardBatch(final JsonCommand command) {
final List<OutwardClearingPresentment> allPending = this.outwardRepository
.findByStatusOrderByIdAsc(OutwardClearingPresentmentStatus.CAPTURED.name());
if (allPending.isEmpty()) {
log.info("Send-pending-batch run found no CAPTURED outward presentments - nothing to send.");
return new CommandProcessingResultBuilder().withCommandId(command == null ? null : command.commandId()).build();
}
// a cheque without both images stays CAPTURED and is skipped rather than blocking the whole batch —
// important for the unattended scheduled job, where one incomplete cheque shouldn't hold up the rest
final List<OutwardClearingPresentment> pending = new ArrayList<>();
final List<Long> skipped = new ArrayList<>();
for (final OutwardClearingPresentment p : allPending) {
if (hasImage(p.getFrontImageStore()) && hasImage(p.getBackImageStore())) {
pending.add(p);
} else {
skipped.add(p.getId());
}
}
if (!skipped.isEmpty()) {
log.warn("Send-pending-batch skipped {} outward presentment(s) missing front/back image: {}", skipped.size(), skipped);
}
if (pending.isEmpty()) {
log.info("Send-pending-batch run found only image-incomplete outward presentments - nothing sent.");
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());
return new CommandProcessingResultBuilder().withCommandId(command == null ? null : command.commandId())
.withEntityId((long) pending.size()).build();
}
private void sendOutwardBatchInternal(final List<OutwardClearingPresentment> items, final String batchReference) {
final String ctsFile = buildCtsFile(batchReference, items);
final String npciResponseJson = this.npciClearingClient.submitOutwardBatch(ctsFile);
for (final OutwardClearingPresentment p : items) {
p.send(batchReference, ctsFile);
p.recordNpciResponse(npciResponseJson);
}
this.outwardRepository.saveAll(items);
}
/**
* Builds the full CTS-2010 outward clearing file for one or more cheques: a batch header (session, grid,
* totals), one instrument record per cheque (MICR, transaction code, drawee bank + IFSC, amount in figures
* &amp; words, image references, IQA), and a batch footer with a record count and CRC32 checksum over
* every instrument. Values captured on each cheque are used as-is; the rest are derived.
*/
private String buildCtsFile(final String batchReference, final List<OutwardClearingPresentment> items) {
final java.time.LocalDate today = DateUtils.getBusinessLocalDate();
final JsonArray instruments = new JsonArray();
java.math.BigDecimal totalAmount = java.math.BigDecimal.ZERO;
int serial = 1;
for (final OutwardClearingPresentment p : items) {
final JsonObject instrument = buildInstrument(p, serial, today);
instruments.add(instrument);
totalAmount = totalAmount.add(p.getAmount() == null ? java.math.BigDecimal.ZERO : p.getAmount());
serial++;
}
// grid zone: shared across the batch — taken from the first cheque that specified one, else default
final OutwardClearingPresentment zoneSource = items.stream()
.filter(p -> p.getGridZoneCode() != null && !p.getGridZoneCode().isBlank()).findFirst().orElse(null);
// ----- header -----
final JsonObject header = new JsonObject();
header.addProperty("clearingHouseCode", "NPCI-CTS");
header.addProperty("gridZoneCode", zoneSource != null ? zoneSource.getGridZoneCode() : "NG");
header.addProperty("gridZoneName", zoneSource != null ? zoneSource.getGridZoneName() : "National Grid");
header.addProperty("sessionId", "SESS-" + today.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) + "-01");
header.addProperty("sessionType", "OUTWARD");
header.addProperty("batchDate", today.toString());
header.addProperty("batchReference", batchReference);
header.addProperty("fileSequenceNumber", batchReference);
header.addProperty("fileGenerationTimestamp", DateUtils.getOffsetDateTimeOfTenant().toString());
header.addProperty("totalInstrumentCount", items.size());
header.addProperty("totalAmount", totalAmount);
header.addProperty("currency", "INR");
// ----- footer (record count + control total + CRC32 checksum over every instrument) -----
final java.util.zip.CRC32 crc = new java.util.zip.CRC32();
crc.update(instruments.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
final JsonObject footer = new JsonObject();
footer.addProperty("recordCount", items.size());
footer.addProperty("controlTotalAmount", totalAmount);
footer.addProperty("endOfFileMarker", "EOF-CTS-BATCH");
footer.addProperty("checksum", "CRC32:" + Long.toHexString(crc.getValue()));
final JsonObject file = new JsonObject();
file.add("batchHeader", header);
file.add("instruments", instruments);
file.add("batchFooter", footer);
return file.toString();
}
/** Builds one instrument (cheque) record within a CTS outward file. */
private JsonObject buildInstrument(final OutwardClearingPresentment p, final int serial, final java.time.LocalDate today) {
final java.math.BigDecimal amount = p.getAmount() == null ? java.math.BigDecimal.ZERO : p.getAmount();
final boolean current = "CURRENT".equalsIgnoreCase(p.getDepositingAccountType());
final String instrumentType = current ? "11" : "10";
final String instrumentTypeDesc = current ? "Current account cheque" : "Savings account cheque";
final String ctsRef = "CTS" + today.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) + String.format("%06d", p.getId());
final JsonObject instrument = new JsonObject();
instrument.addProperty("serialNumber", String.format("%03d", serial));
instrument.addProperty("ctsReferenceNumber", ctsRef);
instrument.addProperty("micrCode", p.getDrawerBankMicr());
instrument.addProperty("instrumentType", instrumentType);
instrument.addProperty("instrumentTypeDescription", instrumentTypeDesc);
instrument.addProperty("transactionCode", instrumentType);
instrument.addProperty("instrumentNumber", p.getChequeNumber());
instrument.addProperty("instrumentDate", p.getInstrumentDate() == null ? null : p.getInstrumentDate().toString());
instrument.addProperty("draweeBankName", p.getDrawerBankName());
instrument.addProperty("draweeBranchIfsc", p.getDrawerIfsc());
instrument.addProperty("drawerAccountNumber", maskAccountNumber(p.getDrawerAccountNumber()));
instrument.addProperty("payeeName", p.getPayeeName());
instrument.addProperty("amount", amount);
instrument.addProperty("amountInWords", amountInWords(amount) + " Only");
final String frontRef = p.getFrontImageRef() != null && !p.getFrontImageRef().isBlank() ? p.getFrontImageRef()
: "IMG-F-" + p.getId();
final String backRef = p.getBackImageRef() != null && !p.getBackImageRef().isBlank() ? p.getBackImageRef() : "IMG-B-" + p.getId();
final JsonObject images = new JsonObject();
images.add("frontImage", buildImageBlock(frontRef, p.getFrontImageStore()));
images.add("backImage", buildImageBlock(backRef, p.getBackImageStore()));
instrument.add("images", images);
// captured on the UI form at deposit time (or the manual "Fill Dummy Data" helper) — not hardcoded
final String iqaStatus = p.getIqaStatus() == null || p.getIqaStatus().isBlank() ? "NA" : p.getIqaStatus();
instrument.addProperty("iqaStatus", iqaStatus);
final String checkResult = "FAIL".equalsIgnoreCase(iqaStatus) ? "FAIL" : "PASS";
final JsonObject iqa = new JsonObject();
iqa.addProperty("skew", checkResult);
iqa.addProperty("legibility", checkResult);
iqa.addProperty("completeness", checkResult);
iqa.addProperty("noise", checkResult);
instrument.add("iqaChecks", iqa);
if (p.getIqaDetail() != null && !p.getIqaDetail().isBlank()) {
instrument.addProperty("iqaDetail", p.getIqaDetail());
}
instrument.addProperty("stopPaymentFlag", p.isStopPaymentFlag());
final String depositBranchCode = p.getOfficeId() == null ? "BR000" : "BR" + String.format("%03d", p.getOfficeId());
instrument.addProperty("depositBranchCode", depositBranchCode);
instrument.addProperty("depositAccountNumber", String.valueOf(p.getDepositingAccountId()));
instrument.addProperty("depositDate", p.getDepositedDate() == null ? null : p.getDepositedDate().toString());
return instrument;
}
/**
* Builds one image block (front or back) for the CTS wire file. When a real file was uploaded (via
* {@link ContentStoreService}, {@code store} is "mimeType|storagePath"), embeds the actual base64 bytes
* plus size/checksum — this is what genuinely travels to NPCI. When nothing was uploaded, returns a bare
* reference-only placeholder instead of fabricating image bytes that don't exist.
*/
private JsonObject buildImageBlock(final String imageRef, final String store) {
final JsonObject image = new JsonObject();
image.addProperty("imageRef", imageRef);
if (store == null || store.isBlank() || !store.contains("|")) {
image.addProperty("format", "NA");
image.addProperty("encoding", "none");
image.addProperty("note", "No image uploaded for this cheque");
return image;
}
final String[] parts = store.split("\\|", 2);
final String mimeType = parts[0];
final String storagePath = parts[1];
try {
final byte[] bytes = this.contentStoreService.download(storagePath).readAllBytes();
image.addProperty("format", mimeType);
image.addProperty("sizeBytes", bytes.length);
image.addProperty("checksum", "MD5:" + md5Hex(bytes));
image.addProperty("encoding", "base64");
image.addProperty("data", java.util.Base64.getEncoder().encodeToString(bytes));
} catch (final Exception e) {
image.addProperty("format", mimeType);
image.addProperty("encoding", "none");
image.addProperty("note", "Stored image could not be read: " + e.getMessage());
}
return image;
}
private String md5Hex(final byte[] bytes) {
try {
final java.security.MessageDigest digest = java.security.MessageDigest.getInstance("MD5");
final byte[] hash = digest.digest(bytes);
final StringBuilder hex = new StringBuilder();
for (final byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (final java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("MD5 is not available", e);
}
}
/** SHA-256 hex digest of the given content, used as a visible integrity fingerprint in the CTS header. */
private String sha256Hex(final String content) {
try {
final java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
final byte[] hash = digest.digest(content.getBytes(java.nio.charset.StandardCharsets.UTF_8));
final StringBuilder hex = new StringBuilder();
for (final byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (final java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is not available", e);
}
}
/** Masks all but the last 4 characters of an account number (matches the CTS file convention of not exposing full numbers). */
private String maskAccountNumber(final String accountNumber) {
if (accountNumber == null || accountNumber.isBlank()) {
return null;
}
final String trimmed = accountNumber.trim();
if (trimmed.length() <= 4) {
return trimmed;
}
final String last4 = trimmed.substring(trimmed.length() - 4);
return "X".repeat(trimmed.length() - 4) + last4;
}
/** Minimal Indian-system number-to-words for whole rupees (paise ignored) used in the CTS file. */
private String amountInWords(final java.math.BigDecimal amount) {
long n = amount.setScale(0, java.math.RoundingMode.DOWN).longValueExact();
if (n == 0) {
return "Zero Rupees";
}
final String[] ones = { "", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve",
"Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen" };
final String[] tens = { "", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety" };
final StringBuilder sb = new StringBuilder();
final long crore = n / 10000000;
n %= 10000000;
final long lakh = n / 100000;
n %= 100000;
final long thousand = n / 1000;
n %= 1000;
final long hundred = n / 100;
final long rest = n % 100;
if (crore > 0) {
sb.append(twoDigits(crore, ones, tens)).append(" Crore ");
}
if (lakh > 0) {
sb.append(twoDigits(lakh, ones, tens)).append(" Lakh ");
}
if (thousand > 0) {
sb.append(twoDigits(thousand, ones, tens)).append(" Thousand ");
}
if (hundred > 0) {
sb.append(ones[(int) hundred]).append(" Hundred ");
}
if (rest > 0) {
sb.append(twoDigits(rest, ones, tens)).append(" ");
}
return sb.append("Rupees").toString().replaceAll("\\s+", " ").trim();
}
private String twoDigits(final long value, final String[] ones, final String[] tens) {
if (value < 20) {
return ones[(int) value];
}
return (tens[(int) (value / 10)] + " " + ones[(int) (value % 10)]).trim();
}
@Transactional
@Override
public CommandProcessingResult settleOutward(final Long presentmentId, final JsonCommand command) {
this.validator.validateNoBody(command.json());
final OutwardClearingPresentment presentment = this.outwardRepositoryWrapper.findOneWithNotFoundDetection(presentmentId);
presentment.settle();
this.outwardRepositoryWrapper.saveAndFlush(presentment);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(presentmentId).build();
}
@Transactional
@Override
public CommandProcessingResult dishonourOutward(final Long presentmentId, final JsonCommand command) {
this.validator.validateForDishonourOutward(command.json());
final OutwardClearingPresentment presentment = this.outwardRepositoryWrapper.findOneWithNotFoundDetection(presentmentId);
final String reasonCode = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.DISHONOUR_REASON_CODE,
command.parsedJson());
this.returnReasonCodeRepository.findByCodeIgnoreCase(reasonCode)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.clearing.return.reason.code.invalid",
"Clearing return reason code " + reasonCode + " is not configured.", reasonCode));
presentment.dishonour(reasonCode);
this.outwardRepositoryWrapper.saveAndFlush(presentment);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(presentmentId).build();
}
// ============================= Positive Pay (RBI PPS) =============================
@Transactional
@Override
public CommandProcessingResult createPositivePay(final JsonCommand command) {
this.validator.validateForCreatePositivePay(command.json());
final JsonElement element = command.parsedJson();
final Locale locale = localeOrDefault(element);
final String accountType = upperOrNull(this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.ACCOUNT_TYPE, element));
final Long accountId = this.fromApiJsonHelper.extractLongNamed(CtsClearingApiConstants.ACCOUNT_ID, element);
final String chequeNumber = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CHEQUE_NUMBER, element);
final LocalDate instrumentDate = this.fromApiJsonHelper.extractLocalDateNamed(CtsClearingApiConstants.INSTRUMENT_DATE, element);
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalNamed(CtsClearingApiConstants.AMOUNT, element, locale);
final String payeeName = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.PAYEE_NAME, element);
final String channel = this.fromApiJsonHelper.extractStringNamed(CtsClearingApiConstants.CHANNEL, element);
final Long userId = this.context.authenticatedUser().getId();
final PositivePayRegistration registration = PositivePayRegistration.register(accountType, accountId, chequeNumber, instrumentDate,
amount, payeeName, channel, userId);
final PositivePayRegistration saved = this.positivePayRepositoryWrapper.saveAndFlush(registration);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()).build();
}
@Transactional
@Override
public CommandProcessingResult cancelPositivePay(final Long registrationId, final JsonCommand command) {
this.validator.validateForCancelPositivePay(command.json());
final PositivePayRegistration registration = this.positivePayRepositoryWrapper.findOneWithNotFoundDetection(registrationId);
final String reason = command.stringValueOfParameterNamedAllowingNull(CtsClearingApiConstants.REASON);
registration.cancel(reason, this.context.authenticatedUser().getId());
this.positivePayRepositoryWrapper.saveAndFlush(registration);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(registrationId).build();
}
// ============================= Cheque images (plain, no command pipeline) =============================
@Transactional
@Override
public void recordInwardItemImage(final Long itemId, final String side, final String mimeTypeAndPath) {
final InwardClearingItem item = this.itemRepositoryWrapper.findOneWithNotFoundDetection(itemId);
item.recordImageStore(side, mimeTypeAndPath);
this.itemRepositoryWrapper.saveAndFlush(item);
}
@Transactional
@Override
public void recordOutwardPresentmentImage(final Long presentmentId, final String side, final String mimeTypeAndPath) {
final OutwardClearingPresentment presentment = this.outwardRepositoryWrapper.findOneWithNotFoundDetection(presentmentId);
presentment.recordImageStore(side, mimeTypeAndPath);
this.outwardRepositoryWrapper.saveAndFlush(presentment);
}
// ============================= Helpers =============================
private void assertDecidable(final InwardClearingItem item) {
if (!item.isValidated()) {
throw new GeneralPlatformDomainRuleException("error.msg.inward.clearing.item.not.validated",
"Inward clearing item " + item.getId() + " has not been validated.", item.getId());
}
if (!item.isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.inward.clearing.item.already.decided",
"Inward clearing item " + item.getId() + " has already been decided.", item.getId());
}
}
/**
* Debits the drawn-on account for the cheque amount using the direct-ledger pattern (a transaction row plus a
* balance-field update), first releasing any hold that validation placed so the reserved funds become the
* debit rather than being counted twice.
*/
private AccountDebit debitCustomerAccount(final InwardClearingItem item, final BigDecimal amount, final LocalDate today) {
if (isSavings(item)) {
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
account.setHelpers(this.savingsAccountTransactionSummaryWrapper, this.savingsHelper);
if (item.isHoldPlaced() && item.getHoldAmount() != null) {
account.releaseOnHoldAmount(item.getHoldAmount());
}
final Office office = account.office();
final Money amountMoney = Money.of(account.getCurrency(), amount);
final SavingsAccountTransaction txn = SavingsAccountTransaction.withdrawal(account, office, null, today, amountMoney, null);
account.addTransaction(txn);
this.savingsAccountTransactionRepository.saveAndFlush(txn);
account.recalculateBalancesAfterFacilityCharges(today);
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
return new AccountDebit(office.getId(), account.getCurrency().getCode());
}
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
if (item.isHoldPlaced() && item.getHoldAmount() != null) {
account.setOnHoldFundsDerived(safe(account.getOnHoldFundsDerived()).subtract(item.getHoldAmount()).max(BigDecimal.ZERO));
}
final Office office = account.getOffice();
final BigDecimal newBalance = safe(account.getAccountBalanceDerived()).subtract(amount);
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, today, null);
txn.setRunningBalanceDerived(newBalance);
this.currentAccountTransactionRepository.saveAndFlush(txn);
account.setAccountBalanceDerived(newBalance);
this.currentAccountRepository.saveAndFlush(account);
return new AccountDebit(office == null ? null : office.getId(), account.getCurrencyCode());
}
/** Releases (without debiting) any hold placed at validation — used when an item is returned. */
private void releaseHoldOnly(final InwardClearingItem item) {
if (!item.isHoldPlaced() || item.getHoldAmount() == null || item.getHoldAmount().signum() <= 0) {
return;
}
if (isSavings(item)) {
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
account.releaseOnHoldAmount(item.getHoldAmount());
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
} else {
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
account.setOnHoldFundsDerived(safe(account.getOnHoldFundsDerived()).subtract(item.getHoldAmount()).max(BigDecimal.ZERO));
this.currentAccountRepository.saveAndFlush(account);
}
}
/** Drives the linked cheque leaf's USED (pass) / RETURNED (return) status, but only when it is still ACTIVE. */
private void markLeaf(final InwardClearingItem item, final boolean passed, final String usedReason, final String returnReason,
final Long userId) {
if (item.getChequeLeafId() == null) {
return;
}
final ChequeLeaf leaf = this.chequeLeafRepository.findById(item.getChequeLeafId()).orElse(null);
if (leaf == null || !ChequeLeafStatus.ACTIVE.name().equals(leaf.getStatus())) {
return;
}
if (passed) {
leaf.markUsed(usedReason, userId);
} else {
leaf.returned(returnReason, userId);
}
this.chequeLeafRepository.saveAndFlush(leaf);
}
/** Once no item of the batch is left PENDING, advance the batch from VALIDATED to DECISIONED. */
private void maybeMarkBatchDecisioned(final InwardClearingBatch batch) {
if (!batch.isValidated()) {
return;
}
final long pending = this.itemRepository.countByBatchIdAndDecision(batch.getId(), "PENDING");
if (pending == 0) {
batch.markDecisioned();
this.batchRepositoryWrapper.saveAndFlush(batch);
}
}
private Long resolveLeafId(final String chequeNumber) {
if (chequeNumber == null) {
return null;
}
try {
final Long serial = Long.valueOf(chequeNumber.trim());
final ChequeLeaf leaf = this.chequeLeafRepository.findByLeafSerialNo(serial).orElse(null);
return leaf == null ? null : leaf.getId();
} catch (final RuntimeException e) {
// non-numeric cheque number, or more than one leaf shares this serial across series — leave unlinked
return null;
}
}
private LocalDate extractItemDate(final String parameter, final JsonElement itemEl, final String dateFormat, final Locale locale) {
if (dateFormat == null || locale == null) {
return null;
}
return this.fromApiJsonHelper.extractLocalDateNamed(parameter, itemEl, dateFormat, locale);
}
private boolean isSavings(final InwardClearingItem item) {
return SAVINGS.equalsIgnoreCase(item.getAccountType());
}
/** The command's locale, defaulting to English so amount parsing never NPEs when the client omits it. */
private Locale localeOrDefault(final JsonElement element) {
final Locale locale = this.fromApiJsonHelper.extractLocaleParameter(element.getAsJsonObject());
return locale == null ? Locale.ENGLISH : locale;
}
private String upperOrNull(final String value) {
return value == null ? null : value.trim().toUpperCase();
}
private BigDecimal safe(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private static final class AccountDebit {
private final Long officeId;
private final String currencyCode;
private AccountDebit(final Long officeId, final String currencyCode) {
this.officeId = officeId;
this.currencyCode = currencyCode;
}
}
}

Some files were not shown because too many files have changed in this diff Show More