Add CFCFRMS (MHA/I4C) Bank API and Simulator module

CFCFRMS integration for cyber fraud complaint management:
- Bank-side: complaint CRUD, lien/freeze/resolve actions, escalation, audit trail, dashboard stats
- Simulator (I4C/MHA): push complaints, hold directives, bulk push, receive updates/escalations, account/transaction verification, bank performance metrics
- Domain: ComplaintTicket, ComplaintAuditLog, CfcfrmsSimulatorLog entities
- Liquibase 0226: 3 tables (MySQL+PostgreSQL), indexes, permissions
- Seed data: 13 realistic complaint tickets + 5 simulator log entries
- JAX-RS endpoints at /v1/cfcfrms/complaints/* and /v1/simulator/cfcfrms/*
- Full Swagger @Operation annotations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-03-18 17:49:54 +05:30
co-authored by Claude Opus 4.6
parent 6a464291d5
commit 78f37257a7
22 changed files with 2758 additions and 0 deletions
@@ -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.cfcfrms.data;
public final class CfcfrmsConstants {
private CfcfrmsConstants() {}
public static final String CFCFRMS_RESOURCE_NAME = "CFCFRMS";
public static final String BANK_IFSC = "LINC0000001";
public static final String BANK_NAME = "LincPay Solutions Pvt Ltd";
}
@@ -0,0 +1,98 @@
/**
* 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.cfcfrms.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Simulator-side audit log for CFCFRMS (I4C/MHA) events.
* Separate from the bank-side complaint tables.
*/
@Entity
@Table(name = "m_cfcfrms_simulator_log")
public class CfcfrmsSimulatorLog extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "cfcfrms_ref", nullable = false, length = 50)
private String cfcfrmsRef;
@Column(name = "ticket_id", length = 50)
private String ticketId;
@Column(name = "bank_ifsc", length = 11)
private String bankIfsc;
/** INBOUND (bank → CFCFRMS) or OUTBOUND (CFCFRMS → bank) */
@Column(name = "direction", nullable = false, length = 10)
private String direction;
@Column(name = "event_type", nullable = false, length = 50)
private String eventType;
@Column(name = "payload", columnDefinition = "TEXT")
private String payload;
@Column(name = "response_payload", columnDefinition = "TEXT")
private String responsePayload;
@Column(name = "event_timestamp", nullable = false)
private OffsetDateTime eventTimestamp;
protected CfcfrmsSimulatorLog() {}
public CfcfrmsSimulatorLog(final String cfcfrmsRef, final String ticketId, final String bankIfsc,
final String direction, final String eventType, final String payload,
final String responsePayload) {
this.cfcfrmsRef = cfcfrmsRef;
this.ticketId = ticketId;
this.bankIfsc = bankIfsc;
this.direction = direction;
this.eventType = eventType;
this.payload = payload;
this.responsePayload = responsePayload;
this.eventTimestamp = OffsetDateTime.now();
}
public CfcfrmsSimulatorLog(final String cfcfrmsRef, final String ticketId, final String bankIfsc,
final String direction, final String eventType, final String payload,
final String responsePayload, final OffsetDateTime eventTimestamp) {
this.cfcfrmsRef = cfcfrmsRef;
this.ticketId = ticketId;
this.bankIfsc = bankIfsc;
this.direction = direction;
this.eventType = eventType;
this.payload = payload;
this.responsePayload = responsePayload;
this.eventTimestamp = eventTimestamp;
}
public String getCfcfrmsRef() { return cfcfrmsRef; }
public String getTicketId() { return ticketId; }
public String getBankIfsc() { return bankIfsc; }
public String getDirection() { return direction; }
public String getEventType() { return eventType; }
public String getPayload() { return payload; }
public String getResponsePayload() { return responsePayload; }
public OffsetDateTime getEventTimestamp() { return eventTimestamp; }
public void setResponsePayload(final String responsePayload) { this.responsePayload = responsePayload; }
}
@@ -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.cfcfrms.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CfcfrmsSimulatorLogRepository extends JpaRepository<CfcfrmsSimulatorLog, Long> {
Optional<CfcfrmsSimulatorLog> findFirstByCfcfrmsRefOrderByEventTimestampDesc(String cfcfrmsRef);
List<CfcfrmsSimulatorLog> findByCfcfrmsRefOrderByEventTimestampAsc(String cfcfrmsRef);
List<CfcfrmsSimulatorLog> findByTicketIdOrderByEventTimestampAsc(String ticketId);
List<CfcfrmsSimulatorLog> findByBankIfscOrderByEventTimestampDesc(String bankIfsc);
long countByBankIfsc(String bankIfsc);
}
@@ -0,0 +1,74 @@
/**
* 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.cfcfrms.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Audit trail for every status change on a CFCFRMS complaint ticket.
*/
@Entity
@Table(name = "m_cfcfrms_complaint_audit_log")
public class ComplaintAuditLog extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "ticket_id", nullable = false, length = 50)
private String ticketId;
@Column(name = "event", nullable = false, length = 50)
private String event;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "actor", nullable = false, length = 200)
private String actor;
@Column(name = "event_timestamp", nullable = false)
private OffsetDateTime eventTimestamp;
protected ComplaintAuditLog() {}
public ComplaintAuditLog(final String ticketId, final String event, final String description,
final String actor) {
this.ticketId = ticketId;
this.event = event;
this.description = description;
this.actor = actor;
this.eventTimestamp = OffsetDateTime.now();
}
public ComplaintAuditLog(final String ticketId, final String event, final String description,
final String actor, final OffsetDateTime eventTimestamp) {
this.ticketId = ticketId;
this.event = event;
this.description = description;
this.actor = actor;
this.eventTimestamp = eventTimestamp;
}
public String getTicketId() { return ticketId; }
public String getEvent() { return event; }
public String getDescription() { return description; }
public String getActor() { return actor; }
public OffsetDateTime getEventTimestamp() { return eventTimestamp; }
}
@@ -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.cfcfrms.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ComplaintAuditLogRepository extends JpaRepository<ComplaintAuditLog, Long> {
List<ComplaintAuditLog> findByTicketIdOrderByEventTimestampAsc(String ticketId);
}
@@ -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.cfcfrms.domain;
public enum ComplaintSource {
HELPLINE_1930("HELPLINE_1930"),
NCRP_PORTAL("NCRP_PORTAL"),
DIRECT_BANK("DIRECT_BANK");
private final String value;
ComplaintSource(final String value) {
this.value = value;
}
public String getValue() {
return value;
}
public static ComplaintSource fromString(final String text) {
for (ComplaintSource source : ComplaintSource.values()) {
if (source.value.equalsIgnoreCase(text)) {
return source;
}
}
throw new IllegalArgumentException("Unknown ComplaintSource: " + text);
}
}
@@ -0,0 +1,60 @@
/**
* 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.cfcfrms.domain;
public enum ComplaintStatus {
RECEIVED("RECEIVED"),
UNDER_REVIEW("UNDER_REVIEW"),
LIEN_MARKED("LIEN_MARKED"),
DEBIT_FROZEN("DEBIT_FROZEN"),
FUNDS_MOVED("FUNDS_MOVED"),
RESOLVED("RESOLVED"),
REJECTED("REJECTED");
private final String value;
ComplaintStatus(final String value) {
this.value = value;
}
public String getValue() {
return value;
}
public boolean canTransitionTo(final ComplaintStatus next) {
return switch (this) {
case RECEIVED -> next == UNDER_REVIEW || next == LIEN_MARKED || next == REJECTED;
case UNDER_REVIEW -> next == LIEN_MARKED || next == DEBIT_FROZEN || next == REJECTED;
case LIEN_MARKED -> next == DEBIT_FROZEN || next == FUNDS_MOVED || next == RESOLVED || next == REJECTED;
case DEBIT_FROZEN -> next == FUNDS_MOVED || next == RESOLVED || next == REJECTED;
case FUNDS_MOVED -> next == RESOLVED;
case RESOLVED, REJECTED -> false;
};
}
public static ComplaintStatus fromString(final String text) {
for (ComplaintStatus status : ComplaintStatus.values()) {
if (status.value.equalsIgnoreCase(text)) {
return status;
}
}
throw new IllegalArgumentException("Unknown ComplaintStatus: " + text);
}
}
@@ -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.cfcfrms.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* CFCFRMS Complaint Ticket — represents a cyber fraud complaint pushed by MHA/I4C to the bank.
*/
@Entity
@Table(name = "m_cfcfrms_complaint_ticket")
public class ComplaintTicket extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "ticket_id", nullable = false, unique = true, length = 50)
private String ticketId;
@Column(name = "complainant_name", nullable = false, length = 200)
private String complainantName;
@Column(name = "complainant_mobile", nullable = false, length = 15)
private String complainantMobile;
@Column(name = "fraud_amount", nullable = false, precision = 19, scale = 2)
private BigDecimal fraudAmount;
@Column(name = "transaction_ref_id", nullable = false, length = 100)
private String transactionRefId;
@Column(name = "fraud_account_number", nullable = false, length = 30)
private String fraudAccountNumber;
@Column(name = "fraud_bank_ifsc", nullable = false, length = 11)
private String fraudBankIfsc;
@Column(name = "victim_account_number", nullable = false, length = 30)
private String victimAccountNumber;
@Column(name = "victim_bank_ifsc", nullable = false, length = 11)
private String victimBankIfsc;
@Column(name = "upi_id", length = 50)
private String upiId;
@Enumerated(EnumType.STRING)
@Column(name = "complaint_source", nullable = false, length = 20)
private ComplaintSource complaintSource;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ComplaintStatus status;
@Column(name = "action_taken", columnDefinition = "TEXT")
private String actionTaken;
@Column(name = "available_balance", precision = 19, scale = 2)
private BigDecimal availableBalance;
@Column(name = "funds_held", precision = 19, scale = 2)
private BigDecimal fundsHeld;
@Column(name = "next_hop_account_number", length = 30)
private String nextHopAccountNumber;
@Column(name = "next_hop_bank_ifsc", length = 11)
private String nextHopBankIfsc;
@Column(name = "received_at", nullable = false)
private OffsetDateTime receivedAt;
@Column(name = "resolved_at")
private OffsetDateTime resolvedAt;
@Column(name = "assigned_officer", nullable = false, length = 200)
private String assignedOfficer;
@Column(name = "state_police_ref", length = 50)
private String statePoliceRef;
protected ComplaintTicket() {}
public static ComplaintTicket create(final String ticketId, final String complainantName, final String complainantMobile,
final BigDecimal fraudAmount, final String transactionRefId, final String fraudAccountNumber,
final String fraudBankIfsc, final String victimAccountNumber, final String victimBankIfsc,
final String upiId, final ComplaintSource complaintSource, final String assignedOfficer,
final String statePoliceRef) {
final ComplaintTicket ticket = new ComplaintTicket();
ticket.ticketId = ticketId;
ticket.complainantName = complainantName;
ticket.complainantMobile = complainantMobile;
ticket.fraudAmount = fraudAmount;
ticket.transactionRefId = transactionRefId;
ticket.fraudAccountNumber = fraudAccountNumber;
ticket.fraudBankIfsc = fraudBankIfsc;
ticket.victimAccountNumber = victimAccountNumber;
ticket.victimBankIfsc = victimBankIfsc;
ticket.upiId = upiId;
ticket.complaintSource = complaintSource;
ticket.status = ComplaintStatus.RECEIVED;
ticket.assignedOfficer = assignedOfficer;
ticket.statePoliceRef = statePoliceRef;
ticket.receivedAt = OffsetDateTime.now();
return ticket;
}
public void updateStatus(final ComplaintStatus newStatus, final String actionNote) {
if (!this.status.canTransitionTo(newStatus)) {
throw new IllegalStateException("Cannot transition from " + this.status + " to " + newStatus);
}
this.status = newStatus;
this.actionTaken = actionNote;
if (newStatus == ComplaintStatus.RESOLVED || newStatus == ComplaintStatus.REJECTED) {
this.resolvedAt = OffsetDateTime.now();
}
}
public void setAvailableBalance(final BigDecimal availableBalance) { this.availableBalance = availableBalance; }
public void setFundsHeld(final BigDecimal fundsHeld) { this.fundsHeld = fundsHeld; }
public void setNextHopAccountNumber(final String nextHopAccountNumber) { this.nextHopAccountNumber = nextHopAccountNumber; }
public void setNextHopBankIfsc(final String nextHopBankIfsc) { this.nextHopBankIfsc = nextHopBankIfsc; }
// ── Accessors ──
public String getTicketId() { return ticketId; }
public String getComplainantName() { return complainantName; }
public String getComplainantMobile() { return complainantMobile; }
public BigDecimal getFraudAmount() { return fraudAmount; }
public String getTransactionRefId() { return transactionRefId; }
public String getFraudAccountNumber() { return fraudAccountNumber; }
public String getFraudBankIfsc() { return fraudBankIfsc; }
public String getVictimAccountNumber() { return victimAccountNumber; }
public String getVictimBankIfsc() { return victimBankIfsc; }
public String getUpiId() { return upiId; }
public ComplaintSource getComplaintSource() { return complaintSource; }
public ComplaintStatus getStatus() { return status; }
public String getActionTaken() { return actionTaken; }
public BigDecimal getAvailableBalance() { return availableBalance; }
public BigDecimal getFundsHeld() { return fundsHeld; }
public String getNextHopAccountNumber() { return nextHopAccountNumber; }
public String getNextHopBankIfsc() { return nextHopBankIfsc; }
public OffsetDateTime getReceivedAt() { return receivedAt; }
public OffsetDateTime getResolvedAt() { return resolvedAt; }
public String getAssignedOfficer() { return assignedOfficer; }
public String getStatePoliceRef() { return statePoliceRef; }
}
@@ -0,0 +1,54 @@
/**
* 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.cfcfrms.domain;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@Repository
public interface ComplaintTicketRepository extends JpaRepository<ComplaintTicket, Long> {
Optional<ComplaintTicket> findByTicketId(String ticketId);
boolean existsByTicketId(String ticketId);
Page<ComplaintTicket> findByStatus(ComplaintStatus status, Pageable pageable);
Page<ComplaintTicket> findByReceivedAtBetween(OffsetDateTime from, OffsetDateTime to, Pageable pageable);
Page<ComplaintTicket> findByStatusAndReceivedAtBetween(ComplaintStatus status, OffsetDateTime from, OffsetDateTime to,
Pageable pageable);
long countByStatus(ComplaintStatus status);
List<ComplaintTicket> findByFraudAccountNumber(String fraudAccountNumber);
@Query("SELECT COALESCE(SUM(t.fraudAmount), 0) FROM ComplaintTicket t")
BigDecimal sumTotalFraudAmount();
@Query("SELECT COALESCE(SUM(t.fundsHeld), 0) FROM ComplaintTicket t WHERE t.fundsHeld IS NOT NULL")
BigDecimal sumTotalFundsHeld();
}
@@ -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.cfcfrms.domain;
public enum DirectiveType {
IMMEDIATE_DEBIT_FREEZE,
LIEN_MARK,
RELEASE
}
@@ -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.cfcfrms.api;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
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.PUT;
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.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintStatsData;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintTicketData;
import org.apache.fineract.portfolio.cfcfrms.data.TimelineEventData;
import org.apache.fineract.portfolio.cfcfrms.service.CfcfrmsComplaintService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
/**
* Bank-side CFCFRMS Complaint Management endpoints.
*
* <pre>
* POST /v1/cfcfrms/complaints – Register a new complaint ticket
* GET /v1/cfcfrms/complaints – List complaint tickets (filterable)
* GET /v1/cfcfrms/complaints/{ticketId} – Get complaint detail
* PUT /v1/cfcfrms/complaints/{ticketId}/action – Take action on a ticket
* GET /v1/cfcfrms/complaints/stats – Dashboard statistics
* GET /v1/cfcfrms/complaints/{ticketId}/timeline – Audit trail
* POST /v1/cfcfrms/complaints/{ticketId}/escalate – Escalate to next bank
* </pre>
*/
@Path("/v1/cfcfrms/complaints")
@Component
@Tag(name = "CFCFRMS Bank Complaints",
description = "Bank-side cyber fraud complaint management — receives complaints from MHA/I4C, "
+ "manages lien/freeze/recovery actions, and escalates to next bank when funds move.")
@RequiredArgsConstructor
public class CfcfrmsComplaintApiResource {
private final PlatformSecurityContext context;
private final CfcfrmsComplaintService complaintService;
private final DefaultToApiJsonSerializer<ComplaintTicketData> ticketSerializer;
private final DefaultToApiJsonSerializer<ComplaintStatsData> statsSerializer;
private final DefaultToApiJsonSerializer<List> listSerializer;
private final DefaultToApiJsonSerializer<Map> mapSerializer;
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a new fraud complaint ticket",
description = "CFCFRMS pushes a new fraud complaint ticket to the bank. "
+ "The bank registers it with status RECEIVED and returns acknowledgement.",
responses = {
@ApiResponse(responseCode = "200", description = "Complaint registered"),
@ApiResponse(responseCode = "400", description = "Invalid request")
})
public String createComplaint(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
final ComplaintTicketData ticket = complaintService.createComplaint(json);
final Map<String, Object> response = new HashMap<>();
response.put("ticketId", ticket.getTicketId());
response.put("status", "RECEIVED");
response.put("message", "Complaint registered successfully");
response.put("receivedAt", ticket.getReceivedAt().toString());
return mapSerializer.serialize(response);
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List all complaint tickets",
description = "List complaint tickets with optional status and date filters for the bank dashboard.",
responses = {
@ApiResponse(responseCode = "200", description = "Complaints listed")
})
public String listComplaints(
@QueryParam("status") @Parameter(description = "Filter by status") final String status,
@QueryParam("fromDate") @Parameter(description = "From date (yyyy-MM-dd)") final String fromDate,
@QueryParam("toDate") @Parameter(description = "To date (yyyy-MM-dd)") final String toDate,
@QueryParam("page") @Parameter(description = "Page number (0-based)") final Integer page,
@QueryParam("size") @Parameter(description = "Page size") final Integer size) {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
final int p = (page != null) ? page : 0;
final int s = (size != null) ? size : 20;
final Page<ComplaintTicketData> result = complaintService.listComplaints(
status, fromDate, toDate, PageRequest.of(p, s, Sort.by(Sort.Direction.DESC, "receivedAt")));
final Map<String, Object> response = new HashMap<>();
response.put("content", result.getContent());
response.put("totalElements", result.getTotalElements());
response.put("totalPages", result.getTotalPages());
response.put("page", result.getNumber());
response.put("size", result.getSize());
return mapSerializer.serialize(response);
}
@GET
@Path("/stats")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get dashboard statistics",
description = "Summary statistics for the complaint management dashboard — "
+ "total complaints, pending, lien marked, debit frozen, resolved, total fraud amount, funds held.",
responses = {
@ApiResponse(responseCode = "200", description = "Statistics retrieved")
})
public String getStats() {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
return statsSerializer.serialize(complaintService.getStats());
}
@GET
@Path("/{ticketId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get complaint ticket detail",
description = "Retrieve full details of a specific complaint ticket by its ticket ID.",
responses = {
@ApiResponse(responseCode = "200", description = "Complaint details"),
@ApiResponse(responseCode = "404", description = "Ticket not found")
})
public String getComplaint(
@PathParam("ticketId") @Parameter(description = "Ticket ID") final String ticketId) {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
return ticketSerializer.serialize(complaintService.getComplaint(ticketId));
}
@PUT
@Path("/{ticketId}/action")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Take action on a complaint ticket",
description = "Bank updates ticket — marks lien, freezes account, reports funds moved, resolves, or rejects. "
+ "Valid transitions: RECEIVED→LIEN_MARKED, LIEN_MARKED→DEBIT_FROZEN, etc.",
responses = {
@ApiResponse(responseCode = "200", description = "Action applied"),
@ApiResponse(responseCode = "400", description = "Invalid action or state transition")
})
public String takeAction(
@PathParam("ticketId") @Parameter(description = "Ticket ID") final String ticketId,
final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return ticketSerializer.serialize(complaintService.takeAction(ticketId, json));
}
@GET
@Path("/{ticketId}/timeline")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get complaint audit trail",
description = "Full audit trail / event log for a complaint ticket — "
+ "every status change with timestamp and actor.",
responses = {
@ApiResponse(responseCode = "200", description = "Timeline retrieved"),
@ApiResponse(responseCode = "404", description = "Ticket not found")
})
public String getTimeline(
@PathParam("ticketId") @Parameter(description = "Ticket ID") final String ticketId) {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
return listSerializer.serialize(complaintService.getTimeline(ticketId));
}
@POST
@Path("/{ticketId}/escalate")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Escalate complaint to next bank",
description = "Escalate ticket when funds have moved to another bank. "
+ "Only valid for FUNDS_MOVED or DEBIT_FROZEN tickets.",
responses = {
@ApiResponse(responseCode = "200", description = "Escalated"),
@ApiResponse(responseCode = "400", description = "Invalid escalation")
})
public String escalate(
@PathParam("ticketId") @Parameter(description = "Ticket ID") final String ticketId,
final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("ESCALATE_CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return mapSerializer.serialize(complaintService.escalate(ticketId, json));
}
}
@@ -0,0 +1,257 @@
/**
* 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.cfcfrms.api;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
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.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cfcfrms.service.CfcfrmsSimulatorService;
import org.springframework.stereotype.Component;
/**
* CFCFRMS Simulator endpoints — simulates the I4C/MHA central CFCFRMS system.
*
* <pre>
* --- OUTBOUND (CFCFRMS → Bank) ---
* POST /v1/simulator/cfcfrms/push-complaint – Push new complaint to bank
* POST /v1/simulator/cfcfrms/push-hold-directive – Send hold/freeze directive
* POST /v1/simulator/cfcfrms/push-bulk-complaints – Bulk push complaints
*
* --- INBOUND (Bank → CFCFRMS) ---
* POST /v1/simulator/cfcfrms/receive-bank-update – Receive status update from bank
* POST /v1/simulator/cfcfrms/receive-escalation – Receive escalation from bank
*
* --- VERIFICATION (CFCFRMS → Bank) ---
* POST /v1/simulator/cfcfrms/verify-account – Verify account at bank
* POST /v1/simulator/cfcfrms/verify-transaction – Verify transaction ref
*
* --- STATUS & REPORTING ---
* GET /v1/simulator/cfcfrms/complaint-status/{ref} – Complaint status from CFCFRMS side
* GET /v1/simulator/cfcfrms/bank-performance – Bank performance metrics
* </pre>
*/
@Path("/v1/simulator/cfcfrms")
@Component
@Tag(name = "CFCFRMS Simulator (I4C/MHA Side)",
description = "Simulates the MHA/I4C CFCFRMS central government platform that pushes fraud complaints to banks, "
+ "receives status updates, escalates across banks, and queries account/transaction verification. "
+ "Use these endpoints to test the full end-to-end complaint lifecycle.")
@RequiredArgsConstructor
public class CfcfrmsSimulatorApiResource {
private final PlatformSecurityContext context;
private final CfcfrmsSimulatorService simulatorService;
private final DefaultToApiJsonSerializer<Map> mapSerializer;
// ═══════════════════════════════════════════════════════════════════════════
// OUTBOUND (CFCFRMS → Bank)
// ═══════════════════════════════════════════════════════════════════════════
@POST
@Path("/push-complaint")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Push a new fraud complaint to the bank",
description = "Simulates I4C pushing a new fraud complaint ticket to the bank. "
+ "In the real CFCFRMS workflow, I4C calls the bank's registered webhook. "
+ "This endpoint creates a CFCFRMS reference number and internally creates a real complaint ticket "
+ "at the bank, demonstrating the end-to-end flow in a single call.",
responses = {
@ApiResponse(responseCode = "200", description = "Complaint pushed to bank successfully"),
@ApiResponse(responseCode = "400", description = "Invalid complaint data")
})
public String pushComplaint(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return mapSerializer.serialize(simulatorService.pushComplaint(json));
}
@POST
@Path("/push-hold-directive")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Send hold/freeze directive to bank",
description = "Simulates I4C sending a direct hold/freeze directive to the bank for a specific account. "
+ "Used in urgent cases bypassing the normal ticket flow — e.g. a court-ordered freeze "
+ "or suspected mule account flagged by law enforcement. "
+ "Directive types: IMMEDIATE_DEBIT_FREEZE, LIEN_MARK, RELEASE. "
+ "If expiryHours is set, the hold auto-releases after N hours if no FIR is filed.",
responses = {
@ApiResponse(responseCode = "200", description = "Directive sent and acknowledged by bank"),
@ApiResponse(responseCode = "400", description = "Invalid directive data")
})
public String pushHoldDirective(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return mapSerializer.serialize(simulatorService.pushHoldDirective(json));
}
@POST
@Path("/push-bulk-complaints")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Push multiple complaints in one call",
description = "Simulates I4C pushing multiple fraud complaint tickets to the bank in a single batch call. "
+ "Typical use case: after a mass phishing campaign or SIM swap fraud ring is detected. "
+ "Maximum 10 complaints per call. Each complaint is processed independently — "
+ "partial failures are reported individually.",
responses = {
@ApiResponse(responseCode = "200", description = "Bulk push results"),
@ApiResponse(responseCode = "400", description = "Invalid request")
})
public String pushBulkComplaints(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CFCFRMS");
final JsonArray complaints = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonArray();
return mapSerializer.serialize(simulatorService.pushBulkComplaints(complaints));
}
// ═══════════════════════════════════════════════════════════════════════════
// INBOUND (Bank → CFCFRMS)
// ═══════════════════════════════════════════════════════════════════════════
@POST
@Path("/receive-bank-update")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Receive status update from the bank",
description = "Simulates the CFCFRMS endpoint that receives status updates FROM the bank. "
+ "In the real world, after taking action (lien, freeze, etc.), the bank calls I4C's API "
+ "to report what was done. This endpoint acknowledges the update and returns the next "
+ "recommended action (AWAIT_POLICE_ORDER, ESCALATE_TO_NEXT_BANK, or CLOSE_TICKET).",
responses = {
@ApiResponse(responseCode = "200", description = "Update acknowledged"),
@ApiResponse(responseCode = "400", description = "Invalid update data")
})
public String receiveBankUpdate(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return mapSerializer.serialize(simulatorService.receiveBankUpdate(json));
}
@POST
@Path("/receive-escalation")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Receive escalation from the bank",
description = "Simulates CFCFRMS receiving an escalation notice from the bank when the fraud funds "
+ "have already moved to the next bank. CFCFRMS generates an escalation reference, "
+ "forwards the complaint to the next bank in the chain, and returns confirmation. "
+ "The fundsMovedTo object contains the target account, bank IFSC, transfer ref, and amount.",
responses = {
@ApiResponse(responseCode = "200", description = "Escalation accepted"),
@ApiResponse(responseCode = "400", description = "Invalid escalation data")
})
public String receiveEscalation(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("ESCALATE_CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return mapSerializer.serialize(simulatorService.receiveEscalation(json));
}
// ═══════════════════════════════════════════════════════════════════════════
// VERIFICATION (CFCFRMS → Bank Query)
// ═══════════════════════════════════════════════════════════════════════════
@POST
@Path("/verify-account")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Verify account at the bank",
description = "CFCFRMS queries the bank to verify if a suspect account exists and get basic details. "
+ "This is done before pushing a complaint ticket to ensure the account is routed to the correct bank. "
+ "Returns account status (ACTIVE/DORMANT/FROZEN/CLOSED), KYC status, "
+ "and whether the account is already flagged in an existing complaint.",
responses = {
@ApiResponse(responseCode = "200", description = "Account verification result")
})
public String verifyAccount(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return mapSerializer.serialize(simulatorService.verifyAccount(json));
}
@POST
@Path("/verify-transaction")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Verify transaction at the bank",
description = "CFCFRMS queries the bank to validate a reported fraud transaction reference. "
+ "Returns whether the transaction was found, its status (COMPLETED/PENDING/REVERSED), "
+ "channel (UPI/IMPS/NEFT/RTGS), and whether funds are currently available for hold.",
responses = {
@ApiResponse(responseCode = "200", description = "Transaction verification result")
})
public String verifyTransaction(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
return mapSerializer.serialize(simulatorService.verifyTransaction(json));
}
// ═══════════════════════════════════════════════════════════════════════════
// STATUS & REPORTING
// ═══════════════════════════════════════════════════════════════════════════
@GET
@Path("/complaint-status/{cfcfrmsRef}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Check complaint status from CFCFRMS side",
description = "Check the current status of a complaint from the CFCFRMS/I4C perspective. "
+ "Includes which bank currently holds the complaint, what action was taken, "
+ "funds held, escalation count, and the full timeline of events.",
responses = {
@ApiResponse(responseCode = "200", description = "Complaint status"),
@ApiResponse(responseCode = "404", description = "CFCFRMS reference not found")
})
public String getComplaintStatus(
@PathParam("cfcfrmsRef") @Parameter(description = "CFCFRMS reference number") final String cfcfrmsRef) {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
return mapSerializer.serialize(simulatorService.getComplaintStatus(cfcfrmsRef));
}
@GET
@Path("/bank-performance")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get bank performance metrics",
description = "CFCFRMS-side view of the bank's performance in handling fraud complaints. "
+ "Shows total complaints received, response time distribution, action counts, "
+ "resolution rate, and TAT compliance rate — used by I4C to evaluate bank responsiveness.",
responses = {
@ApiResponse(responseCode = "200", description = "Bank performance metrics")
})
public String getBankPerformance() {
this.context.authenticatedUser().validateHasReadPermission("CFCFRMS");
return mapSerializer.serialize(simulatorService.getBankPerformance());
}
}
@@ -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.cfcfrms.data;
import java.math.BigDecimal;
public final class ComplaintStatsData {
private final long totalComplaints;
private final long pendingReview;
private final long lienMarked;
private final long debitFrozen;
private final long fundsRecovered;
private final BigDecimal totalFraudAmount;
private final BigDecimal totalFundsHeld;
private final String resolutionRate;
public ComplaintStatsData(final long totalComplaints, final long pendingReview, final long lienMarked,
final long debitFrozen, final long fundsRecovered, final BigDecimal totalFraudAmount,
final BigDecimal totalFundsHeld, final String resolutionRate) {
this.totalComplaints = totalComplaints;
this.pendingReview = pendingReview;
this.lienMarked = lienMarked;
this.debitFrozen = debitFrozen;
this.fundsRecovered = fundsRecovered;
this.totalFraudAmount = totalFraudAmount;
this.totalFundsHeld = totalFundsHeld;
this.resolutionRate = resolutionRate;
}
public long getTotalComplaints() { return totalComplaints; }
public long getPendingReview() { return pendingReview; }
public long getLienMarked() { return lienMarked; }
public long getDebitFrozen() { return debitFrozen; }
public long getFundsRecovered() { return fundsRecovered; }
public BigDecimal getTotalFraudAmount() { return totalFraudAmount; }
public BigDecimal getTotalFundsHeld() { return totalFundsHeld; }
public String getResolutionRate() { return resolutionRate; }
}
@@ -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.cfcfrms.data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicket;
public final class ComplaintTicketData {
private final String ticketId;
private final String complainantName;
private final String complainantMobile;
private final BigDecimal fraudAmount;
private final String transactionRefId;
private final String fraudAccountNumber;
private final String fraudBankIfsc;
private final String victimAccountNumber;
private final String victimBankIfsc;
private final String upiId;
private final String complaintSource;
private final String status;
private final String actionTaken;
private final BigDecimal availableBalance;
private final BigDecimal fundsHeld;
private final String nextHopAccountNumber;
private final String nextHopBankIfsc;
private final OffsetDateTime receivedAt;
private final OffsetDateTime resolvedAt;
private final String assignedOfficer;
private final String statePoliceRef;
private ComplaintTicketData(final String ticketId, final String complainantName, final String complainantMobile,
final BigDecimal fraudAmount, final String transactionRefId, final String fraudAccountNumber,
final String fraudBankIfsc, final String victimAccountNumber, final String victimBankIfsc,
final String upiId, final String complaintSource, final String status, final String actionTaken,
final BigDecimal availableBalance, final BigDecimal fundsHeld, final String nextHopAccountNumber,
final String nextHopBankIfsc, final OffsetDateTime receivedAt, final OffsetDateTime resolvedAt,
final String assignedOfficer, final String statePoliceRef) {
this.ticketId = ticketId;
this.complainantName = complainantName;
this.complainantMobile = complainantMobile;
this.fraudAmount = fraudAmount;
this.transactionRefId = transactionRefId;
this.fraudAccountNumber = fraudAccountNumber;
this.fraudBankIfsc = fraudBankIfsc;
this.victimAccountNumber = victimAccountNumber;
this.victimBankIfsc = victimBankIfsc;
this.upiId = upiId;
this.complaintSource = complaintSource;
this.status = status;
this.actionTaken = actionTaken;
this.availableBalance = availableBalance;
this.fundsHeld = fundsHeld;
this.nextHopAccountNumber = nextHopAccountNumber;
this.nextHopBankIfsc = nextHopBankIfsc;
this.receivedAt = receivedAt;
this.resolvedAt = resolvedAt;
this.assignedOfficer = assignedOfficer;
this.statePoliceRef = statePoliceRef;
}
public static ComplaintTicketData fromEntity(final ComplaintTicket t) {
return new ComplaintTicketData(
t.getTicketId(), t.getComplainantName(), t.getComplainantMobile(),
t.getFraudAmount(), t.getTransactionRefId(), t.getFraudAccountNumber(),
t.getFraudBankIfsc(), t.getVictimAccountNumber(), t.getVictimBankIfsc(),
t.getUpiId(), t.getComplaintSource().getValue(), t.getStatus().getValue(),
t.getActionTaken(), t.getAvailableBalance(), t.getFundsHeld(),
t.getNextHopAccountNumber(), t.getNextHopBankIfsc(), t.getReceivedAt(),
t.getResolvedAt(), t.getAssignedOfficer(), t.getStatePoliceRef());
}
// Accessors
public String getTicketId() { return ticketId; }
public String getComplainantName() { return complainantName; }
public String getComplainantMobile() { return complainantMobile; }
public BigDecimal getFraudAmount() { return fraudAmount; }
public String getTransactionRefId() { return transactionRefId; }
public String getFraudAccountNumber() { return fraudAccountNumber; }
public String getFraudBankIfsc() { return fraudBankIfsc; }
public String getVictimAccountNumber() { return victimAccountNumber; }
public String getVictimBankIfsc() { return victimBankIfsc; }
public String getUpiId() { return upiId; }
public String getComplaintSource() { return complaintSource; }
public String getStatus() { return status; }
public String getActionTaken() { return actionTaken; }
public BigDecimal getAvailableBalance() { return availableBalance; }
public BigDecimal getFundsHeld() { return fundsHeld; }
public String getNextHopAccountNumber() { return nextHopAccountNumber; }
public String getNextHopBankIfsc() { return nextHopBankIfsc; }
public OffsetDateTime getReceivedAt() { return receivedAt; }
public OffsetDateTime getResolvedAt() { return resolvedAt; }
public String getAssignedOfficer() { return assignedOfficer; }
public String getStatePoliceRef() { return statePoliceRef; }
}
@@ -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.cfcfrms.data;
import java.time.OffsetDateTime;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintAuditLog;
public final class TimelineEventData {
private final String event;
private final String description;
private final OffsetDateTime timestamp;
private final String actor;
public TimelineEventData(final String event, final String description, final OffsetDateTime timestamp,
final String actor) {
this.event = event;
this.description = description;
this.timestamp = timestamp;
this.actor = actor;
}
public static TimelineEventData fromEntity(final ComplaintAuditLog log) {
return new TimelineEventData(log.getEvent(), log.getDescription(), log.getEventTimestamp(), log.getActor());
}
public String getEvent() { return event; }
public String getDescription() { return description; }
public OffsetDateTime getTimestamp() { return timestamp; }
public String getActor() { return actor; }
}
@@ -0,0 +1,280 @@
/**
* 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.cfcfrms.seed;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.cfcfrms.domain.CfcfrmsSimulatorLog;
import org.apache.fineract.portfolio.cfcfrms.domain.CfcfrmsSimulatorLogRepository;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintAuditLog;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintAuditLogRepository;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintSource;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintStatus;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicket;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicketRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Seeds CFCFRMS complaint tickets, audit logs, and simulator logs on startup.
*/
@Component
@RequiredArgsConstructor
public class CfcfrmsDataSeeder {
private static final Logger LOG = LoggerFactory.getLogger(CfcfrmsDataSeeder.class);
private final ComplaintTicketRepository ticketRepository;
private final ComplaintAuditLogRepository auditLogRepository;
private final CfcfrmsSimulatorLogRepository simulatorLogRepository;
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void seed() {
if (ticketRepository.count() > 0) {
LOG.info("CFCFRMS data already seeded, skipping.");
return;
}
LOG.info("Seeding CFCFRMS complaint data...");
final OffsetDateTime now = OffsetDateTime.now();
// ─── 3 RECEIVED ──────────────────────────────────────────────
seedTicket("TKT-20250318-001", "Rajesh Kumar Sharma", "9876543210",
new BigDecimal("45000.00"), "UPI/402987654321/0318",
"50100234567890", "HDFC0001234", "10234567890123", "SBIN0005678",
"fraud123@ybl", ComplaintSource.HELPLINE_1930, ComplaintStatus.RECEIVED,
null, null, null, null, null,
"Inspector Meena Kumari", "KA/BLR/CC/2025/1001",
now.minusHours(2));
seedTicket("TKT-20250318-002", "Priya Venkatesh", "8765432109",
new BigDecimal("125000.00"), "NEFT/SBIN0001234/2025031800123",
"91234000567890", "ICIC0004567", "30456789012345", "PUNB0123400",
null, ComplaintSource.NCRP_PORTAL, ComplaintStatus.RECEIVED,
null, null, null, null, null,
"Sub-Inspector Rakesh Yadav", "TN/CHN/CC/2025/2045",
now.minusHours(1));
seedTicket("TKT-20250317-003", "Mohammad Asif Khan", "7654321098",
new BigDecimal("8500.00"), "UPI/709876543210/0317",
"60234567890123", "BARB0DELAVI", "20567890123456", "CNRB0001234",
"scammer99@paytm", ComplaintSource.DIRECT_BANK, ComplaintStatus.RECEIVED,
null, null, null, null, null,
"Inspector Deepak Tiwari", "UP/LKO/CC/2025/3078",
now.minusDays(1));
// ─── 3 LIEN_MARKED ───────────────────────────────────────────
seedTicket("TKT-20250315-004", "Sunita Devi Agarwal", "9988776655",
new BigDecimal("250000.00"), "RTGS/HDFC0001234/2025031500456",
"40200987654321", "HDFC0001234", "50100123456789", "HDFC0009876",
null, ComplaintSource.HELPLINE_1930, ComplaintStatus.LIEN_MARKED,
"Lien of Rs.2,50,000 marked on account 40200987654321",
new BigDecimal("312000.00"), new BigDecimal("250000.00"), null, null,
"Inspector Anita Sharma", "MH/MUM/CC/2025/4012",
now.minusDays(3));
seedTicket("TKT-20250314-005", "Vikram Singh Rathore", "9123456780",
new BigDecimal("500000.00"), "NEFT/ICIC0006789/2025031400789",
"70345678901234", "ICIC0006789", "80456789012345", "UTIB0002345",
null, ComplaintSource.NCRP_PORTAL, ComplaintStatus.LIEN_MARKED,
"Lien marked; suspect account flagged for monitoring",
new BigDecimal("523000.00"), new BigDecimal("500000.00"), null, null,
"DSP Ramesh Chandra", "RJ/JPR/CC/2025/5034",
now.minusDays(4));
seedTicket("TKT-20250313-006", "Lakshmi Narayanan", "8899776655",
new BigDecimal("75000.00"), "UPI/812345678901/0313",
"10567890123456", "SBIN0012345", "20678901234567", "IOBA0001234",
"fake.merchant@okaxis", ComplaintSource.HELPLINE_1930, ComplaintStatus.LIEN_MARKED,
"Lien of Rs.75,000 marked; UPI ID reported to NPCI",
new BigDecimal("78500.00"), new BigDecimal("75000.00"), null, null,
"Inspector Kavitha Reddy", "AP/HYD/CC/2025/6056",
now.minusDays(5));
// ─── 2 DEBIT_FROZEN ──────────────────────────────────────────
seedTicket("TKT-20250310-007", "Amit Patel", "9234567890",
new BigDecimal("180000.00"), "IMPS/934567890123/0310",
"30789012345678", "PUNB0567800", "40890123456789", "BKID0001234",
null, ComplaintSource.NCRP_PORTAL, ComplaintStatus.DEBIT_FROZEN,
"Account debit frozen by branch manager; suspect notified under RBI guidelines",
new BigDecimal("195000.00"), new BigDecimal("180000.00"), null, null,
"Inspector Suresh Babu", "GJ/AMD/CC/2025/7078",
now.minusDays(8));
seedTicket("TKT-20250308-008", "Neha Gupta", "8345678901",
new BigDecimal("95000.00"), "UPI/345678901234/0308",
"50901234567890", "KKBK0000123", "60012345678901", "YESB0001234",
"invest.profit@axl", ComplaintSource.HELPLINE_1930, ComplaintStatus.DEBIT_FROZEN,
"Debit freeze applied; investment fraud confirmed by cyber cell",
new BigDecimal("102000.00"), new BigDecimal("95000.00"), null, null,
"SI Manish Kumar", "DL/NDL/CC/2025/8090",
now.minusDays(10));
// ─── 2 FUNDS_MOVED ───────────────────────────────────────────
seedTicket("TKT-20250305-009", "Sanjay Mehta", "7456789012",
new BigDecimal("320000.00"), "RTGS/UTIB0002345/2025030500321",
"70123456789012", "UTIB0002345", "80234567890123", "MAHB0001234",
null, ComplaintSource.NCRP_PORTAL, ComplaintStatus.FUNDS_MOVED,
"Funds traced to ICICI account; lien could not hold",
new BigDecimal("1200.00"), BigDecimal.ZERO,
"90345678901234", "ICIC0009876",
"DSP Prabhakar Rao", "KA/MYS/CC/2025/9112",
now.minusDays(13));
seedTicket("TKT-20250303-010", "Aarti Joshi", "6567890123",
new BigDecimal("47500.00"), "UPI/567890123456/0303",
"10456789012345", "SBIN0034567", "20567890123456", "CNRB0005678",
"reward.claim@ibl", ComplaintSource.HELPLINE_1930, ComplaintStatus.FUNDS_MOVED,
"Funds moved to Paytm Payments Bank; UPI trail identified",
new BigDecimal("350.00"), BigDecimal.ZERO,
"91567890123456", "PYTM0123456",
"Inspector Geeta Mishra", "MP/BPL/CC/2025/1134",
now.minusDays(15));
// ─── 2 RESOLVED ─────────────────────────────────────────────
seedTicket("TKT-20250228-011", "Ravi Shankar Dubey", "9678901234",
new BigDecimal("65000.00"), "IMPS/678901234567/0228",
"30678901234567", "BARB0MUMBAI", "40789012345678", "UBIN0123456",
null, ComplaintSource.NCRP_PORTAL, ComplaintStatus.RESOLVED,
"Full amount recovered and refunded to victim. FIR filed, suspect arrested.",
new BigDecimal("65000.00"), new BigDecimal("65000.00"), null, null,
"Inspector Vijay Patil", "MH/PUN/CC/2025/1256",
now.minusDays(18));
seedTicket("TKT-20250225-012", "Pooja Krishnamurthy", "8789012345",
new BigDecimal("15000.00"), "UPI/789012345678/0225",
"50789012345678", "KKBK0004567", "60890123456789", "FDRL0001234",
"lottery.win@ptm", ComplaintSource.DIRECT_BANK, ComplaintStatus.RESOLVED,
"Amount reversed through UPI dispute resolution. Suspect UPI ID blocked by NPCI.",
new BigDecimal("15000.00"), new BigDecimal("15000.00"), null, null,
"SI Arjun Nair", "KL/EKM/CC/2025/1378",
now.minusDays(21));
// ─── 1 REJECTED ─────────────────────────────────────────────
seedTicket("TKT-20250220-013", "Dinesh Choudhary", "7890123456",
new BigDecimal("5000.00"), "UPI/890123456789/0220",
"70890123456789", "HDFC0007890", "80901234567890", "SBIN0098765",
"merchant.real@upi", ComplaintSource.DIRECT_BANK, ComplaintStatus.REJECTED,
"Legitimate transaction. Complainant confirmed genuine purchase by family member.",
null, null, null, null,
"Inspector Savita Deshpande", "MH/NGP/CC/2025/1490",
now.minusDays(26));
// ─── Simulator Logs (5 historical entries) ──────────────────
seedSimulatorLogs(now);
LOG.info("CFCFRMS data seeded: 13 tickets, audit logs, and 5 simulator log entries.");
}
private void seedTicket(final String ticketId, final String name, final String mobile,
final BigDecimal amount, final String txnRef, final String fraudAcct, final String fraudIfsc,
final String victimAcct, final String victimIfsc, final String upiId,
final ComplaintSource source, final ComplaintStatus status, final String action,
final BigDecimal balance, final BigDecimal held, final String nextHopAcct,
final String nextHopIfsc, final String officer, final String policeRef,
final OffsetDateTime receivedAt) {
final ComplaintTicket ticket = ComplaintTicket.create(ticketId, name, mobile, amount, txnRef,
fraudAcct, fraudIfsc, victimAcct, victimIfsc, upiId, source, officer, policeRef);
// Apply status changes for non-RECEIVED tickets
if (status != ComplaintStatus.RECEIVED) {
if (nextHopAcct != null) {
ticket.setNextHopAccountNumber(nextHopAcct);
ticket.setNextHopBankIfsc(nextHopIfsc);
}
// Use reflection-free approach: update via the entity method for valid transitions
applyStatusPath(ticket, status, action);
}
if (balance != null) { ticket.setAvailableBalance(balance); }
if (held != null) { ticket.setFundsHeld(held); }
ticketRepository.save(ticket);
// Audit: RECEIVED
auditLogRepository.save(new ComplaintAuditLog(ticketId, "COMPLAINT_RECEIVED",
"Complaint received from " + source.getValue() + " for fraud amount Rs." + amount,
"CFCFRMS_SYSTEM", receivedAt));
// Audit: status change
if (status != ComplaintStatus.RECEIVED) {
auditLogRepository.save(new ComplaintAuditLog(ticketId,
"STATUS_CHANGED_TO_" + status.getValue(), action, officer, receivedAt.plusHours(2)));
}
}
private void applyStatusPath(final ComplaintTicket ticket, final ComplaintStatus target, final String action) {
// Walk through valid transitions to reach target
if (target == ComplaintStatus.LIEN_MARKED) {
ticket.updateStatus(ComplaintStatus.LIEN_MARKED, action);
} else if (target == ComplaintStatus.DEBIT_FROZEN) {
ticket.updateStatus(ComplaintStatus.LIEN_MARKED, action);
ticket.updateStatus(ComplaintStatus.DEBIT_FROZEN, action);
} else if (target == ComplaintStatus.FUNDS_MOVED) {
ticket.updateStatus(ComplaintStatus.LIEN_MARKED, action);
ticket.updateStatus(ComplaintStatus.FUNDS_MOVED, action);
} else if (target == ComplaintStatus.RESOLVED) {
ticket.updateStatus(ComplaintStatus.LIEN_MARKED, action);
ticket.updateStatus(ComplaintStatus.RESOLVED, action);
} else if (target == ComplaintStatus.REJECTED) {
ticket.updateStatus(ComplaintStatus.REJECTED, action);
}
}
private void seedSimulatorLogs(final OffsetDateTime now) {
simulatorLogRepository.saveAll(List.of(
new CfcfrmsSimulatorLog("CFCFRMS-2025-0315-4012", "TKT-20250315-004", "HDFC0001234",
"OUTBOUND", "PUSH_COMPLAINT",
"{\"complainantName\":\"Sunita Devi Agarwal\",\"fraudAmount\":250000}",
"{\"ticketId\":\"TKT-20250315-004\",\"status\":\"RECEIVED\"}",
now.minusDays(3)),
new CfcfrmsSimulatorLog("CFCFRMS-2025-0315-4012", "TKT-20250315-004", "HDFC0001234",
"INBOUND", "BANK_UPDATE_LIEN_MARKED",
"{\"actionTaken\":\"LIEN_MARKED\",\"fundsHeld\":250000}",
"{\"nextAction\":\"AWAIT_POLICE_ORDER\"}",
now.minusDays(2)),
new CfcfrmsSimulatorLog("CFCFRMS-2025-0305-9112", "TKT-20250305-009", "UTIB0002345",
"OUTBOUND", "PUSH_COMPLAINT",
"{\"complainantName\":\"Sanjay Mehta\",\"fraudAmount\":320000}",
"{\"ticketId\":\"TKT-20250305-009\",\"status\":\"RECEIVED\"}",
now.minusDays(13)),
new CfcfrmsSimulatorLog("CFCFRMS-2025-0305-9112", "TKT-20250305-009", "UTIB0002345",
"INBOUND", "ESCALATION_RECEIVED",
"{\"fundsMovedTo\":{\"bankIfsc\":\"ICIC0009876\",\"amount\":320000}}",
"{\"escalationRef\":\"ESC-2025-0305-7712\",\"escalatedTo\":\"ICIC0009876\"}",
now.minusDays(10)),
new CfcfrmsSimulatorLog("CFCFRMS-2025-0228-1256", "TKT-20250228-011", "BARB0MUMBAI",
"INBOUND", "BANK_UPDATE_RESOLVED",
"{\"actionTaken\":\"RESOLVED\",\"fundsHeld\":65000}",
"{\"nextAction\":\"CLOSE_TICKET\"}",
now.minusDays(5))
));
}
}
@@ -0,0 +1,52 @@
/**
* 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.cfcfrms.service;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.Map;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintStatsData;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintTicketData;
import org.apache.fineract.portfolio.cfcfrms.data.TimelineEventData;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface CfcfrmsComplaintService {
/** Create a new complaint ticket (bank-side). Returns created ticket data. */
ComplaintTicketData createComplaint(JsonObject requestBody);
/** List complaint tickets with optional filters. */
Page<ComplaintTicketData> listComplaints(String status, String fromDate, String toDate, Pageable pageable);
/** Get single complaint by ticketId. */
ComplaintTicketData getComplaint(String ticketId);
/** Bank takes action on a ticket (lien, freeze, funds_moved, resolve, reject). */
ComplaintTicketData takeAction(String ticketId, JsonObject requestBody);
/** Dashboard statistics. */
ComplaintStatsData getStats();
/** Audit timeline for a ticket. */
List<TimelineEventData> getTimeline(String ticketId);
/** Escalate a ticket to the next bank. Returns escalation result map. */
Map<String, Object> escalate(String ticketId, JsonObject requestBody);
}
@@ -0,0 +1,247 @@
/**
* 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.cfcfrms.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintStatsData;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintTicketData;
import org.apache.fineract.portfolio.cfcfrms.data.TimelineEventData;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintAuditLog;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintAuditLogRepository;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintSource;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintStatus;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicket;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicketRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class CfcfrmsComplaintServiceImpl implements CfcfrmsComplaintService {
private static final Logger LOG = LoggerFactory.getLogger(CfcfrmsComplaintServiceImpl.class);
private final ComplaintTicketRepository ticketRepository;
private final ComplaintAuditLogRepository auditLogRepository;
@Override
public ComplaintTicketData createComplaint(final JsonObject json) {
final String ticketId = generateTicketId();
if (ticketRepository.existsByTicketId(ticketId)) {
throw new IllegalArgumentException("Ticket already exists: " + ticketId);
}
final ComplaintTicket ticket = ComplaintTicket.create(
ticketId,
getStringOrThrow(json, "complainantName"),
getStringOrThrow(json, "complainantMobile"),
getBigDecimalOrThrow(json, "fraudAmount"),
getStringOrThrow(json, "transactionRefId"),
getStringOrThrow(json, "fraudAccountNumber"),
getStringOrThrow(json, "fraudBankIfsc"),
getStringOrThrow(json, "victimAccountNumber"),
getStringOrThrow(json, "victimBankIfsc"),
getStringOrNull(json, "upiId"),
ComplaintSource.fromString(getStringOrThrow(json, "complaintSource")),
getStringOrThrow(json, "assignedOfficer"),
getStringOrNull(json, "statePoliceRef"));
ticketRepository.saveAndFlush(ticket);
auditLogRepository.save(new ComplaintAuditLog(ticketId, "COMPLAINT_RECEIVED",
"Complaint received from " + ticket.getComplaintSource().getValue()
+ " for fraud amount Rs." + ticket.getFraudAmount(),
"CFCFRMS_SYSTEM"));
LOG.info("CFCFRMS complaint ticket created: {}", ticketId);
return ComplaintTicketData.fromEntity(ticket);
}
@Override
@Transactional(readOnly = true)
public Page<ComplaintTicketData> listComplaints(final String status, final String fromDate,
final String toDate, final Pageable pageable) {
Page<ComplaintTicket> page;
if (status != null && fromDate != null && toDate != null) {
page = ticketRepository.findByStatusAndReceivedAtBetween(
ComplaintStatus.fromString(status), parseDate(fromDate), parseDate(toDate).plusDays(1), pageable);
} else if (status != null) {
page = ticketRepository.findByStatus(ComplaintStatus.fromString(status), pageable);
} else if (fromDate != null && toDate != null) {
page = ticketRepository.findByReceivedAtBetween(parseDate(fromDate), parseDate(toDate).plusDays(1), pageable);
} else {
page = ticketRepository.findAll(pageable);
}
return page.map(ComplaintTicketData::fromEntity);
}
@Override
@Transactional(readOnly = true)
public ComplaintTicketData getComplaint(final String ticketId) {
return ComplaintTicketData.fromEntity(findTicketOrThrow(ticketId));
}
@Override
public ComplaintTicketData takeAction(final String ticketId, final JsonObject json) {
final ComplaintTicket ticket = findTicketOrThrow(ticketId);
final ComplaintStatus newStatus = ComplaintStatus.fromString(getStringOrThrow(json, "action"));
final String actionNote = getStringOrThrow(json, "actionNote");
if (newStatus == ComplaintStatus.FUNDS_MOVED) {
final String nextHop = getStringOrNull(json, "nextHopAccountNumber");
final String nextIfsc = getStringOrNull(json, "nextHopBankIfsc");
if (nextHop == null || nextHop.isBlank() || nextIfsc == null || nextIfsc.isBlank()) {
throw new IllegalArgumentException("nextHopAccountNumber and nextHopBankIfsc are required for FUNDS_MOVED");
}
ticket.setNextHopAccountNumber(nextHop);
ticket.setNextHopBankIfsc(nextIfsc);
}
ticket.updateStatus(newStatus, actionNote);
if (json.has("availableBalance") && !json.get("availableBalance").isJsonNull()) {
ticket.setAvailableBalance(json.get("availableBalance").getAsBigDecimal());
}
if (json.has("fundsHeld") && !json.get("fundsHeld").isJsonNull()) {
ticket.setFundsHeld(json.get("fundsHeld").getAsBigDecimal());
}
ticketRepository.saveAndFlush(ticket);
auditLogRepository.save(new ComplaintAuditLog(ticketId, "STATUS_CHANGED_TO_" + newStatus.getValue(),
actionNote, ticket.getAssignedOfficer()));
LOG.info("CFCFRMS ticket {} action: -> {}", ticketId, newStatus);
return ComplaintTicketData.fromEntity(ticket);
}
@Override
@Transactional(readOnly = true)
public ComplaintStatsData getStats() {
final long total = ticketRepository.count();
final long pending = ticketRepository.countByStatus(ComplaintStatus.RECEIVED)
+ ticketRepository.countByStatus(ComplaintStatus.UNDER_REVIEW);
final long lien = ticketRepository.countByStatus(ComplaintStatus.LIEN_MARKED);
final long frozen = ticketRepository.countByStatus(ComplaintStatus.DEBIT_FROZEN);
final long resolved = ticketRepository.countByStatus(ComplaintStatus.RESOLVED);
final BigDecimal totalFraud = ticketRepository.sumTotalFraudAmount();
final BigDecimal totalHeld = ticketRepository.sumTotalFundsHeld();
final String rate = total > 0
? BigDecimal.valueOf(resolved * 100.0 / total).setScale(1, RoundingMode.HALF_UP) + "%"
: "0.0%";
return new ComplaintStatsData(total, pending, lien, frozen, resolved, totalFraud, totalHeld, rate);
}
@Override
@Transactional(readOnly = true)
public List<TimelineEventData> getTimeline(final String ticketId) {
findTicketOrThrow(ticketId);
return auditLogRepository.findByTicketIdOrderByEventTimestampAsc(ticketId)
.stream().map(TimelineEventData::fromEntity).collect(Collectors.toList());
}
@Override
public Map<String, Object> escalate(final String ticketId, final JsonObject json) {
final ComplaintTicket ticket = findTicketOrThrow(ticketId);
if (ticket.getStatus() != ComplaintStatus.FUNDS_MOVED && ticket.getStatus() != ComplaintStatus.DEBIT_FROZEN) {
throw new IllegalStateException(
"Can only escalate tickets in FUNDS_MOVED or DEBIT_FROZEN status. Current: " + ticket.getStatus());
}
final String nextHop = getStringOrThrow(json, "nextHopAccountNumber");
final String nextIfsc = getStringOrThrow(json, "nextHopBankIfsc");
final String remarks = getStringOrNull(json, "remarks");
ticket.setNextHopAccountNumber(nextHop);
ticket.setNextHopBankIfsc(nextIfsc);
ticketRepository.saveAndFlush(ticket);
final String escalationRef = "ESC-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
auditLogRepository.save(new ComplaintAuditLog(ticketId, "ESCALATED",
"Escalated to " + nextIfsc + " / " + nextHop + ". Remarks: " + (remarks != null ? remarks : "N/A"),
ticket.getAssignedOfficer()));
LOG.info("CFCFRMS ticket {} escalated to {}", ticketId, nextIfsc);
final Map<String, Object> result = new HashMap<>();
result.put("escalatedTo", nextIfsc);
result.put("escalationRef", escalationRef);
result.put("timestamp", OffsetDateTime.now().toString());
return result;
}
// ── Helpers ──
private ComplaintTicket findTicketOrThrow(final String ticketId) {
return ticketRepository.findByTicketId(ticketId)
.orElseThrow(() -> new IllegalArgumentException("Complaint ticket not found: " + ticketId));
}
private String generateTicketId() {
final String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
final int seq = (int) (ticketRepository.count() + 1);
return String.format("TKT-%s-%03d", date, seq);
}
private OffsetDateTime parseDate(final String dateStr) {
return LocalDate.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay().atOffset(ZoneOffset.UTC);
}
private String getStringOrThrow(final JsonObject json, final String field) {
if (!json.has(field) || json.get(field).isJsonNull()) {
throw new IllegalArgumentException("Missing required field: " + field);
}
return json.get(field).getAsString();
}
private String getStringOrNull(final JsonObject json, final String field) {
return (json.has(field) && !json.get(field).isJsonNull()) ? json.get(field).getAsString() : null;
}
private BigDecimal getBigDecimalOrThrow(final JsonObject json, final String field) {
if (!json.has(field) || json.get(field).isJsonNull()) {
throw new IllegalArgumentException("Missing required field: " + field);
}
return json.get(field).getAsBigDecimal();
}
}
@@ -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.cfcfrms.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.Map;
public interface CfcfrmsSimulatorService {
/** Push a single complaint to the bank (CFCFRMS → Bank). */
Map<String, Object> pushComplaint(JsonObject requestBody);
/** Push a hold/freeze directive to the bank. */
Map<String, Object> pushHoldDirective(JsonObject requestBody);
/** Push multiple complaints in one call. */
Map<String, Object> pushBulkComplaints(JsonArray complaints);
/** Receive a bank status update (Bank → CFCFRMS). */
Map<String, Object> receiveBankUpdate(JsonObject requestBody);
/** Receive an escalation from the bank. */
Map<String, Object> receiveEscalation(JsonObject requestBody);
/** Verify an account exists at the bank. */
Map<String, Object> verifyAccount(JsonObject requestBody);
/** Verify a transaction reference. */
Map<String, Object> verifyTransaction(JsonObject requestBody);
/** Get complaint status from CFCFRMS side. */
Map<String, Object> getComplaintStatus(String cfcfrmsRef);
/** Get bank performance metrics. */
Map<String, Object> getBankPerformance();
}
@@ -0,0 +1,389 @@
/**
* 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.cfcfrms.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.cfcfrms.data.CfcfrmsConstants;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintTicketData;
import org.apache.fineract.portfolio.cfcfrms.data.TimelineEventData;
import org.apache.fineract.portfolio.cfcfrms.domain.CfcfrmsSimulatorLog;
import org.apache.fineract.portfolio.cfcfrms.domain.CfcfrmsSimulatorLogRepository;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicket;
import org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicketRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService {
private static final Logger LOG = LoggerFactory.getLogger(CfcfrmsSimulatorServiceImpl.class);
private static final Random RANDOM = new Random();
private final CfcfrmsSimulatorLogRepository simulatorLogRepository;
private final ComplaintTicketRepository ticketRepository;
private final CfcfrmsComplaintService complaintService;
@Override
public Map<String, Object> pushComplaint(final JsonObject requestBody) {
final String cfcfrmsRef = generateCfcfrmsRef();
// Create the real ticket via bank-side service
final ComplaintTicketData ticketData = complaintService.createComplaint(requestBody);
// Log in simulator table
simulatorLogRepository.save(new CfcfrmsSimulatorLog(
cfcfrmsRef, ticketData.getTicketId(),
requestBody.has("fraudBankIfsc") ? requestBody.get("fraudBankIfsc").getAsString() : CfcfrmsConstants.BANK_IFSC,
"OUTBOUND", "PUSH_COMPLAINT", requestBody.toString(),
"{\"ticketId\":\"" + ticketData.getTicketId() + "\",\"status\":\"RECEIVED\"}"));
LOG.info("CFCFRMS Simulator: Pushed complaint {} -> ticket {}", cfcfrmsRef, ticketData.getTicketId());
final Map<String, Object> response = new LinkedHashMap<>();
response.put("cfcfrmsRef", cfcfrmsRef);
response.put("ticketId", ticketData.getTicketId());
response.put("pushedToBankAt", OffsetDateTime.now().toString());
response.put("bankAcknowledged", true);
response.put("message", "Complaint pushed to " + CfcfrmsConstants.BANK_IFSC + " successfully");
return response;
}
@Override
public Map<String, Object> pushHoldDirective(final JsonObject json) {
final String cfcfrmsRef = getStringOrThrow(json, "cfcfrmsRef");
final String directiveRef = "DIR-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
+ "-" + String.format("%04d", RANDOM.nextInt(10000));
final String directiveType = getStringOrThrow(json, "directiveType");
final String targetAccount = getStringOrThrow(json, "targetAccountNumber");
// Simulate bank acknowledgement
String bankResponse = "ACKNOWLEDGED";
// Check if account exists in our complaints
final List<ComplaintTicket> related = ticketRepository.findByFraudAccountNumber(targetAccount);
if (related.isEmpty()) {
bankResponse = "ACKNOWLEDGED"; // Still acknowledge in simulation
}
simulatorLogRepository.save(new CfcfrmsSimulatorLog(
cfcfrmsRef, null,
json.has("targetBankIfsc") ? json.get("targetBankIfsc").getAsString() : CfcfrmsConstants.BANK_IFSC,
"OUTBOUND", "HOLD_DIRECTIVE_" + directiveType, json.toString(),
"{\"directiveRef\":\"" + directiveRef + "\",\"bankResponse\":\"" + bankResponse + "\"}"));
LOG.info("CFCFRMS Simulator: Hold directive {} type={} -> {}", directiveRef, directiveType, bankResponse);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("directiveRef", directiveRef);
response.put("status", "DIRECTIVE_SENT");
response.put("bankResponse", bankResponse);
response.put("timestamp", OffsetDateTime.now().toString());
return response;
}
@Override
public Map<String, Object> pushBulkComplaints(final JsonArray complaints) {
if (complaints.size() > 10) {
throw new IllegalArgumentException("Maximum 10 complaints per bulk call");
}
final List<Map<String, Object>> results = new ArrayList<>();
int successful = 0;
int failed = 0;
for (int i = 0; i < complaints.size(); i++) {
final JsonObject complaint = complaints.get(i).getAsJsonObject();
try {
final Map<String, Object> pushResult = pushComplaint(complaint);
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("cfcfrmsRef", pushResult.get("cfcfrmsRef"));
entry.put("ticketId", pushResult.get("ticketId"));
entry.put("status", "PUSHED");
results.add(entry);
successful++;
} catch (Exception e) {
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("cfcfrmsRef", generateCfcfrmsRef());
entry.put("error", e.getMessage());
entry.put("status", "FAILED");
results.add(entry);
failed++;
}
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("totalPushed", complaints.size());
response.put("successful", successful);
response.put("failed", failed);
response.put("results", results);
return response;
}
@Override
public Map<String, Object> receiveBankUpdate(final JsonObject json) {
final String cfcfrmsRef = getStringOrThrow(json, "cfcfrmsRef");
final String ticketId = getStringOrThrow(json, "ticketId");
final String actionTaken = getStringOrThrow(json, "actionTaken");
simulatorLogRepository.save(new CfcfrmsSimulatorLog(
cfcfrmsRef, ticketId,
json.has("bankIfsc") ? json.get("bankIfsc").getAsString() : CfcfrmsConstants.BANK_IFSC,
"INBOUND", "BANK_UPDATE_" + actionTaken, json.toString(), null));
// Determine next action
String nextAction;
String message;
switch (actionTaken) {
case "LIEN_MARKED":
nextAction = "AWAIT_POLICE_ORDER";
message = "Update received. Lien confirmed. Awaiting police order for further action.";
break;
case "DEBIT_FROZEN":
nextAction = "AWAIT_POLICE_ORDER";
message = "Update received. Debit freeze confirmed. Awaiting FIR and court order.";
break;
case "FUNDS_MOVED":
nextAction = "ESCALATE_TO_NEXT_BANK";
message = "Update received. Funds moved. Escalation to next bank initiated.";
break;
case "RESOLVED":
nextAction = "CLOSE_TICKET";
message = "Update received. Complaint resolved. Ticket will be closed.";
break;
case "REJECTED":
nextAction = "CLOSE_TICKET";
message = "Update received. Complaint rejected by bank.";
break;
default:
nextAction = "AWAIT_POLICE_ORDER";
message = "Update received.";
}
LOG.info("CFCFRMS Simulator: Received bank update for {} -> {}", cfcfrmsRef, actionTaken);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("cfcfrmsRef", cfcfrmsRef);
response.put("updateAcknowledged", true);
response.put("nextAction", nextAction);
response.put("message", message);
return response;
}
@Override
public Map<String, Object> receiveEscalation(final JsonObject json) {
final String cfcfrmsRef = getStringOrThrow(json, "cfcfrmsRef");
final String ticketId = getStringOrThrow(json, "ticketId");
final String escalationRef = "ESC-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MMdd"))
+ "-" + String.format("%04d", RANDOM.nextInt(10000));
// Extract nextBank from fundsMovedTo
String nextBankIfsc = "";
String nextBankName = "";
if (json.has("fundsMovedTo") && !json.get("fundsMovedTo").isJsonNull()) {
final JsonObject fundsMovedTo = json.getAsJsonObject("fundsMovedTo");
nextBankIfsc = fundsMovedTo.has("bankIfsc") ? fundsMovedTo.get("bankIfsc").getAsString() : "";
nextBankName = fundsMovedTo.has("bankName") ? fundsMovedTo.get("bankName").getAsString() : "";
}
simulatorLogRepository.save(new CfcfrmsSimulatorLog(
cfcfrmsRef, ticketId,
json.has("fromBankIfsc") ? json.get("fromBankIfsc").getAsString() : CfcfrmsConstants.BANK_IFSC,
"INBOUND", "ESCALATION_RECEIVED", json.toString(),
"{\"escalationRef\":\"" + escalationRef + "\",\"escalatedTo\":\"" + nextBankIfsc + "\"}"));
LOG.info("CFCFRMS Simulator: Escalation received for {} -> {}", cfcfrmsRef, nextBankIfsc);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("cfcfrmsRef", cfcfrmsRef);
response.put("escalationRef", escalationRef);
response.put("escalatedTo", nextBankIfsc);
response.put("status", "ESCALATION_ACCEPTED");
response.put("message", "Complaint escalated to " + nextBankName + ". Ticket forwarded.");
return response;
}
@Override
@Transactional(readOnly = true)
public Map<String, Object> verifyAccount(final JsonObject json) {
final String accountNumber = getStringOrThrow(json, "accountNumber");
// Simulate verification — check if account appears in any complaint
final List<ComplaintTicket> tickets = ticketRepository.findByFraudAccountNumber(accountNumber);
final boolean exists = !tickets.isEmpty() || RANDOM.nextBoolean(); // simulate for unknown accounts
final Map<String, Object> response = new LinkedHashMap<>();
response.put("accountExists", exists);
response.put("accountStatus", exists ? "ACTIVE" : "NOT_FOUND");
response.put("accountType", "SAVINGS");
response.put("linkedMobile", "98XXXXX210");
response.put("kycStatus", "FULL_KYC");
response.put("isAlreadyFlagged", !tickets.isEmpty());
response.put("existingTickets", tickets.size());
return response;
}
@Override
@Transactional(readOnly = true)
public Map<String, Object> verifyTransaction(final JsonObject json) {
final String transactionRefId = getStringOrThrow(json, "transactionRefId");
final BigDecimal amount = json.has("amount") && !json.get("amount").isJsonNull()
? json.get("amount").getAsBigDecimal() : BigDecimal.ZERO;
// Simulate transaction verification
final Map<String, Object> response = new LinkedHashMap<>();
response.put("transactionFound", true);
response.put("transactionStatus", "COMPLETED");
response.put("transactionTimestamp", OffsetDateTime.now().minusHours(2).toString());
response.put("senderAccount", json.has("accountNumber") ? json.get("accountNumber").getAsString() : "UNKNOWN");
response.put("receiverAccount", json.has("accountNumber") ? json.get("accountNumber").getAsString() : "UNKNOWN");
// Determine channel from ref
String channel = "UPI";
if (transactionRefId.contains("NEFT")) {
channel = "NEFT";
} else if (transactionRefId.contains("RTGS")) {
channel = "RTGS";
} else if (transactionRefId.contains("IMPS")) {
channel = "IMPS";
}
response.put("channel", channel);
response.put("amountMatches", true);
response.put("fundsCurrentlyAvailable", true);
return response;
}
@Override
@Transactional(readOnly = true)
public Map<String, Object> getComplaintStatus(final String cfcfrmsRef) {
final CfcfrmsSimulatorLog log = simulatorLogRepository.findFirstByCfcfrmsRefOrderByEventTimestampDesc(cfcfrmsRef)
.orElseThrow(() -> new IllegalArgumentException("CFCFRMS reference not found: " + cfcfrmsRef));
final Map<String, Object> response = new LinkedHashMap<>();
response.put("cfcfrmsRef", cfcfrmsRef);
response.put("ticketId", log.getTicketId());
response.put("currentBank", log.getBankIfsc());
// Get complaint status from bank-side
String complaintStatus = "UNKNOWN";
BigDecimal fundsHeld = BigDecimal.ZERO;
List<TimelineEventData> timeline = List.of();
if (log.getTicketId() != null) {
try {
final ComplaintTicketData ticket = complaintService.getComplaint(log.getTicketId());
complaintStatus = ticket.getStatus();
fundsHeld = ticket.getFundsHeld() != null ? ticket.getFundsHeld() : BigDecimal.ZERO;
timeline = complaintService.getTimeline(log.getTicketId());
} catch (Exception e) {
LOG.warn("Could not fetch bank-side ticket for {}: {}", log.getTicketId(), e.getMessage());
}
}
response.put("complaintStatus", complaintStatus);
response.put("lastUpdatedBy", CfcfrmsConstants.BANK_NAME);
response.put("lastUpdatedAt", log.getEventTimestamp().toString());
response.put("fundsHeld", fundsHeld);
// Escalation count
final long escalations = simulatorLogRepository.findByCfcfrmsRefOrderByEventTimestampAsc(cfcfrmsRef)
.stream().filter(l -> "ESCALATION_RECEIVED".equals(l.getEventType())).count();
response.put("escalationCount", escalations);
// Convert timeline
final List<Map<String, Object>> timelineList = timeline.stream().map(t -> {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("event", t.getEvent());
m.put("description", t.getDescription());
m.put("timestamp", t.getTimestamp().toString());
m.put("actor", t.getActor());
return m;
}).collect(Collectors.toList());
response.put("timeline", timelineList);
return response;
}
@Override
@Transactional(readOnly = true)
public Map<String, Object> getBankPerformance() {
final String bankIfsc = CfcfrmsConstants.BANK_IFSC;
final long totalComplaints = ticketRepository.count();
// Simulate response times
final long respondedWithin1Hour = Math.round(totalComplaints * 0.65);
final long respondedWithin24Hours = Math.round(totalComplaints * 0.93);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("bankIfsc", bankIfsc);
response.put("bankName", CfcfrmsConstants.BANK_NAME);
response.put("totalComplaintsReceived", totalComplaints);
response.put("respondedWithin1Hour", respondedWithin1Hour);
response.put("respondedWithin24Hours", respondedWithin24Hours);
response.put("avgResponseTimeMinutes", 38);
response.put("lienMarkedCount", ticketRepository.countByStatus(
org.apache.fineract.portfolio.cfcfrms.domain.ComplaintStatus.LIEN_MARKED));
response.put("debitFrozenCount", ticketRepository.countByStatus(
org.apache.fineract.portfolio.cfcfrms.domain.ComplaintStatus.DEBIT_FROZEN));
response.put("fundsMovedOut", ticketRepository.countByStatus(
org.apache.fineract.portfolio.cfcfrms.domain.ComplaintStatus.FUNDS_MOVED));
response.put("resolvedCount", ticketRepository.countByStatus(
org.apache.fineract.portfolio.cfcfrms.domain.ComplaintStatus.RESOLVED));
final String tatCompliance = totalComplaints > 0
? BigDecimal.valueOf(respondedWithin24Hours * 100.0 / totalComplaints)
.setScale(1, java.math.RoundingMode.HALF_UP) + "%"
: "0.0%";
response.put("tatComplianceRate", tatCompliance);
response.put("lastActivityAt", OffsetDateTime.now().toString());
return response;
}
// ── Helpers ──
private String generateCfcfrmsRef() {
final String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MMdd"));
return "CFCFRMS-" + date + "-" + String.format("%04d", RANDOM.nextInt(10000));
}
private String getStringOrThrow(final JsonObject json, final String field) {
if (!json.has(field) || json.get(field).isJsonNull()) {
throw new IllegalArgumentException("Missing required field: " + field);
}
return json.get(field).getAsString();
}
}
@@ -244,4 +244,5 @@
<include file="parts/0223_add_sanctions_screening_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0224_add_mobile_lookup_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0225_add_current_account_support.xml" relativeToChangelogFile="true"/>
<include file="parts/0226_add_cfcfrms_tables.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,426 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<!--
CFCFRMS (Citizen Financial Cyber Fraud Reporting and Management System)
MHA/I4C Bank Integration tables.
Tables created:
m_cfcfrms_complaint_ticket – Fraud complaint tickets received from CFCFRMS
m_cfcfrms_complaint_audit_log – Audit trail for each ticket status change
m_cfcfrms_simulator_log – I4C/MHA simulator-side event log
Permissions inserted:
READ_CFCFRMS, CREATE_CFCFRMS, UPDATE_CFCFRMS, ESCALATE_CFCFRMS
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_cfcfrms_complaint_ticket
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="1" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cfcfrms_complaint_ticket"/></not>
</preConditions>
<createTable tableName="m_cfcfrms_complaint_ticket">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="ticket_id" type="VARCHAR(50)">
<constraints nullable="false" unique="true"/>
</column>
<column name="complainant_name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="complainant_mobile" type="VARCHAR(15)">
<constraints nullable="false"/>
</column>
<column name="fraud_amount" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="transaction_ref_id" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="fraud_account_number" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="fraud_bank_ifsc" type="VARCHAR(11)">
<constraints nullable="false"/>
</column>
<column name="victim_account_number" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="victim_bank_ifsc" type="VARCHAR(11)">
<constraints nullable="false"/>
</column>
<column name="upi_id" type="VARCHAR(50)"/>
<column name="complaint_source" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="action_taken" type="TEXT"/>
<column name="available_balance" type="DECIMAL(19,2)"/>
<column name="funds_held" type="DECIMAL(19,2)"/>
<column name="next_hop_account_number" type="VARCHAR(30)"/>
<column name="next_hop_bank_ifsc" type="VARCHAR(11)"/>
<column name="received_at" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="resolved_at" type="DATETIME"/>
<column name="assigned_officer" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="state_police_ref" type="VARCHAR(50)"/>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- PostgreSQL – m_cfcfrms_complaint_ticket -->
<changeSet author="fineract" id="1" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cfcfrms_complaint_ticket"/></not>
</preConditions>
<createTable tableName="m_cfcfrms_complaint_ticket">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="ticket_id" type="VARCHAR(50)">
<constraints nullable="false" unique="true"/>
</column>
<column name="complainant_name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="complainant_mobile" type="VARCHAR(15)">
<constraints nullable="false"/>
</column>
<column name="fraud_amount" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="transaction_ref_id" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="fraud_account_number" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="fraud_bank_ifsc" type="VARCHAR(11)">
<constraints nullable="false"/>
</column>
<column name="victim_account_number" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="victim_bank_ifsc" type="VARCHAR(11)">
<constraints nullable="false"/>
</column>
<column name="upi_id" type="VARCHAR(50)"/>
<column name="complaint_source" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="action_taken" type="TEXT"/>
<column name="available_balance" type="DECIMAL(19,2)"/>
<column name="funds_held" type="DECIMAL(19,2)"/>
<column name="next_hop_account_number" type="VARCHAR(30)"/>
<column name="next_hop_bank_ifsc" type="VARCHAR(11)"/>
<column name="received_at" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="resolved_at" type="TIMESTAMP WITH TIME ZONE"/>
<column name="assigned_officer" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="state_police_ref" type="VARCHAR(50)"/>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_cfcfrms_complaint_audit_log
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="2" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cfcfrms_complaint_audit_log"/></not>
</preConditions>
<createTable tableName="m_cfcfrms_complaint_audit_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="ticket_id" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="event" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="description" type="TEXT"/>
<column name="actor" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="event_timestamp" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- PostgreSQL – m_cfcfrms_complaint_audit_log -->
<changeSet author="fineract" id="2" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cfcfrms_complaint_audit_log"/></not>
</preConditions>
<createTable tableName="m_cfcfrms_complaint_audit_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="ticket_id" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="event" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="description" type="TEXT"/>
<column name="actor" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="event_timestamp" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_cfcfrms_simulator_log
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="3" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cfcfrms_simulator_log"/></not>
</preConditions>
<createTable tableName="m_cfcfrms_simulator_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="cfcfrms_ref" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="ticket_id" type="VARCHAR(50)"/>
<column name="bank_ifsc" type="VARCHAR(11)"/>
<column name="direction" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="event_type" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="payload" type="TEXT"/>
<column name="response_payload" type="TEXT"/>
<column name="event_timestamp" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- PostgreSQL – m_cfcfrms_simulator_log -->
<changeSet author="fineract" id="3" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cfcfrms_simulator_log"/></not>
</preConditions>
<createTable tableName="m_cfcfrms_simulator_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="cfcfrms_ref" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="ticket_id" type="VARCHAR(50)"/>
<column name="bank_ifsc" type="VARCHAR(11)"/>
<column name="direction" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="event_type" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="payload" type="TEXT"/>
<column name="response_payload" type="TEXT"/>
<column name="event_timestamp" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- Indexes -->
<changeSet author="fineract" id="4">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_cfcfrms_ticket_id"/></not>
</preConditions>
<createIndex tableName="m_cfcfrms_complaint_ticket" indexName="idx_cfcfrms_ticket_id">
<column name="ticket_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="5">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_cfcfrms_ticket_status"/></not>
</preConditions>
<createIndex tableName="m_cfcfrms_complaint_ticket" indexName="idx_cfcfrms_ticket_status">
<column name="status"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="6">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_cfcfrms_fraud_account"/></not>
</preConditions>
<createIndex tableName="m_cfcfrms_complaint_ticket" indexName="idx_cfcfrms_fraud_account">
<column name="fraud_account_number"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="7">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_cfcfrms_audit_ticket_id"/></not>
</preConditions>
<createIndex tableName="m_cfcfrms_complaint_audit_log" indexName="idx_cfcfrms_audit_ticket_id">
<column name="ticket_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="8">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_cfcfrms_sim_ref"/></not>
</preConditions>
<createIndex tableName="m_cfcfrms_simulator_log" indexName="idx_cfcfrms_sim_ref">
<column name="cfcfrms_ref"/>
</createIndex>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
Permissions
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="9">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code = 'READ_CFCFRMS'
</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_CFCFRMS"/>
<column name="entity_name" value="CFCFRMS"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_CFCFRMS"/>
<column name="entity_name" value="CFCFRMS"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_CFCFRMS"/>
<column name="entity_name" value="CFCFRMS"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="ESCALATE_CFCFRMS"/>
<column name="entity_name" value="CFCFRMS"/>
<column name="action_name" value="ESCALATE"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>