From 78f37257a7d87a23777b9e850eb9fef89501ee39 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 18 Mar 2026 17:49:54 +0530 Subject: [PATCH] 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) --- .../cfcfrms/data/CfcfrmsConstants.java | 28 ++ .../cfcfrms/domain/CfcfrmsSimulatorLog.java | 98 ++++ .../domain/CfcfrmsSimulatorLogRepository.java | 38 ++ .../cfcfrms/domain/ComplaintAuditLog.java | 74 +++ .../domain/ComplaintAuditLogRepository.java | 29 ++ .../cfcfrms/domain/ComplaintSource.java | 45 ++ .../cfcfrms/domain/ComplaintStatus.java | 60 +++ .../cfcfrms/domain/ComplaintTicket.java | 166 +++++++ .../domain/ComplaintTicketRepository.java | 54 +++ .../cfcfrms/domain/DirectiveType.java | 26 ++ .../api/CfcfrmsComplaintApiResource.java | 221 +++++++++ .../api/CfcfrmsSimulatorApiResource.java | 257 +++++++++++ .../cfcfrms/data/ComplaintStatsData.java | 55 +++ .../cfcfrms/data/ComplaintTicketData.java | 112 +++++ .../cfcfrms/data/TimelineEventData.java | 47 ++ .../cfcfrms/seed/CfcfrmsDataSeeder.java | 280 ++++++++++++ .../service/CfcfrmsComplaintService.java | 52 +++ .../service/CfcfrmsComplaintServiceImpl.java | 247 ++++++++++ .../service/CfcfrmsSimulatorService.java | 53 +++ .../service/CfcfrmsSimulatorServiceImpl.java | 389 ++++++++++++++++ .../db/changelog/tenant/changelog-tenant.xml | 1 + .../tenant/parts/0226_add_cfcfrms_tables.xml | 426 ++++++++++++++++++ 22 files changed, 2758 insertions(+) create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/CfcfrmsConstants.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLogRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLogRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintSource.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintStatus.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicket.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicketRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/DirectiveType.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintStatsData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintTicketData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/TimelineEventData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0226_add_cfcfrms_tables.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/CfcfrmsConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/CfcfrmsConstants.java new file mode 100644 index 000000000..355a4ec3d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/CfcfrmsConstants.java @@ -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"; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java new file mode 100644 index 000000000..2408432df --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java @@ -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 { + + @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; } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLogRepository.java new file mode 100644 index 000000000..e423411e4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLogRepository.java @@ -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 { + + Optional findFirstByCfcfrmsRefOrderByEventTimestampDesc(String cfcfrmsRef); + + List findByCfcfrmsRefOrderByEventTimestampAsc(String cfcfrmsRef); + + List findByTicketIdOrderByEventTimestampAsc(String ticketId); + + List findByBankIfscOrderByEventTimestampDesc(String bankIfsc); + + long countByBankIfsc(String bankIfsc); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java new file mode 100644 index 000000000..6dc8ca92e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java @@ -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 { + + @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; } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLogRepository.java new file mode 100644 index 000000000..51a0880ae --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLogRepository.java @@ -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 { + + List findByTicketIdOrderByEventTimestampAsc(String ticketId); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintSource.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintSource.java new file mode 100644 index 000000000..d7a9ba43c --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintSource.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintStatus.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintStatus.java new file mode 100644 index 000000000..a847a464d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintStatus.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicket.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicket.java new file mode 100644 index 000000000..03bcbd476 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicket.java @@ -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 { + + @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; } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicketRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicketRepository.java new file mode 100644 index 000000000..f4d22d993 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintTicketRepository.java @@ -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 { + + Optional findByTicketId(String ticketId); + + boolean existsByTicketId(String ticketId); + + Page findByStatus(ComplaintStatus status, Pageable pageable); + + Page findByReceivedAtBetween(OffsetDateTime from, OffsetDateTime to, Pageable pageable); + + Page findByStatusAndReceivedAtBetween(ComplaintStatus status, OffsetDateTime from, OffsetDateTime to, + Pageable pageable); + + long countByStatus(ComplaintStatus status); + + List 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(); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/DirectiveType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/DirectiveType.java new file mode 100644 index 000000000..140f0f29d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/DirectiveType.java @@ -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 +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java new file mode 100644 index 000000000..3daf73e02 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java @@ -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. + * + *
+ *   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
+ * 
+ */ +@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 ticketSerializer; + private final DefaultToApiJsonSerializer statsSerializer; + private final DefaultToApiJsonSerializer listSerializer; + private final DefaultToApiJsonSerializer 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 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 result = complaintService.listComplaints( + status, fromDate, toDate, PageRequest.of(p, s, Sort.by(Sort.Direction.DESC, "receivedAt"))); + + final Map 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)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java new file mode 100644 index 000000000..a64fecca5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java @@ -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. + * + *
+ * --- 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
+ * 
+ */ +@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 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintStatsData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintStatsData.java new file mode 100644 index 000000000..d717c5ba7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintStatsData.java @@ -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; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintTicketData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintTicketData.java new file mode 100644 index 000000000..e9c2e5a7b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/ComplaintTicketData.java @@ -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; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/TimelineEventData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/TimelineEventData.java new file mode 100644 index 000000000..556b575a5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/data/TimelineEventData.java @@ -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; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java new file mode 100644 index 000000000..851e9e734 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java @@ -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)) + )); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintService.java new file mode 100644 index 000000000..88b16f949 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintService.java @@ -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 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 getTimeline(String ticketId); + + /** Escalate a ticket to the next bank. Returns escalation result map. */ + Map escalate(String ticketId, JsonObject requestBody); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java new file mode 100644 index 000000000..378f0eb35 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java @@ -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 listComplaints(final String status, final String fromDate, + final String toDate, final Pageable pageable) { + Page 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 getTimeline(final String ticketId) { + findTicketOrThrow(ticketId); + return auditLogRepository.findByTicketIdOrderByEventTimestampAsc(ticketId) + .stream().map(TimelineEventData::fromEntity).collect(Collectors.toList()); + } + + @Override + public Map 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorService.java new file mode 100644 index 000000000..380d99659 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorService.java @@ -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 pushComplaint(JsonObject requestBody); + + /** Push a hold/freeze directive to the bank. */ + Map pushHoldDirective(JsonObject requestBody); + + /** Push multiple complaints in one call. */ + Map pushBulkComplaints(JsonArray complaints); + + /** Receive a bank status update (Bank → CFCFRMS). */ + Map receiveBankUpdate(JsonObject requestBody); + + /** Receive an escalation from the bank. */ + Map receiveEscalation(JsonObject requestBody); + + /** Verify an account exists at the bank. */ + Map verifyAccount(JsonObject requestBody); + + /** Verify a transaction reference. */ + Map verifyTransaction(JsonObject requestBody); + + /** Get complaint status from CFCFRMS side. */ + Map getComplaintStatus(String cfcfrmsRef); + + /** Get bank performance metrics. */ + Map getBankPerformance(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java new file mode 100644 index 000000000..20f2d1175 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java @@ -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 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 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 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 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 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 pushBulkComplaints(final JsonArray complaints) { + if (complaints.size() > 10) { + throw new IllegalArgumentException("Maximum 10 complaints per bulk call"); + } + + final List> 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 pushResult = pushComplaint(complaint); + final Map 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 entry = new LinkedHashMap<>(); + entry.put("cfcfrmsRef", generateCfcfrmsRef()); + entry.put("error", e.getMessage()); + entry.put("status", "FAILED"); + results.add(entry); + failed++; + } + } + + final Map 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 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 response = new LinkedHashMap<>(); + response.put("cfcfrmsRef", cfcfrmsRef); + response.put("updateAcknowledged", true); + response.put("nextAction", nextAction); + response.put("message", message); + return response; + } + + @Override + public Map 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 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 verifyAccount(final JsonObject json) { + final String accountNumber = getStringOrThrow(json, "accountNumber"); + + // Simulate verification — check if account appears in any complaint + final List tickets = ticketRepository.findByFraudAccountNumber(accountNumber); + final boolean exists = !tickets.isEmpty() || RANDOM.nextBoolean(); // simulate for unknown accounts + + final Map 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 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 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 getComplaintStatus(final String cfcfrmsRef) { + final CfcfrmsSimulatorLog log = simulatorLogRepository.findFirstByCfcfrmsRefOrderByEventTimestampDesc(cfcfrmsRef) + .orElseThrow(() -> new IllegalArgumentException("CFCFRMS reference not found: " + cfcfrmsRef)); + + final Map 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 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> timelineList = timeline.stream().map(t -> { + final Map 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 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 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(); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 5da7d21b8..990322cd2 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -244,4 +244,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0226_add_cfcfrms_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0226_add_cfcfrms_tables.xml new file mode 100644 index 000000000..12192eaa9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0226_add_cfcfrms_tables.xml @@ -0,0 +1,426 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_CFCFRMS' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +