| 1 |
:-( |
defmodule DaProductAppWeb.Api.V1.NpciCallbackController do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Controller for handling NPCI-initiated callbacks as per UPI International specification. |
| 4 |
|
|
| 5 |
|
NPCI makes these calls to our PSP: |
| 6 |
|
1. ReqPay (CREDIT) - Already handled by InternationalTransactionController |
| 7 |
|
2. ReqChkTxn - Check transaction status (timeout scenario) |
| 8 |
|
3. ReqPay (REVERSAL) - Initiate reversal (timeout scenario) |
| 9 |
|
4. ReqHbt - Heartbeat check |
| 10 |
|
""" |
| 11 |
:-( |
use DaProductAppWeb, :controller |
| 12 |
|
|
| 13 |
|
alias DaProductApp.Transactions.UpiInternationalService |
| 14 |
|
# Note: UpiXmlParser needs to be implemented for full XML parsing |
| 15 |
|
# For now we'll use a simplified parser |
| 16 |
|
|
| 17 |
|
action_fallback DaProductAppWeb.FallbackController |
| 18 |
|
|
| 19 |
|
@doc """ |
| 20 |
|
Handle NPCI ReqChkTxn - Check transaction status |
| 21 |
|
Called by NPCI after 30 seconds if we don't respond to ReqPay (CREDIT) |
| 22 |
|
""" |
| 23 |
|
def check_transaction(conn, %{"xml" => xml_string}) do |
| 24 |
:-( |
case parse_simple_xml(xml_string) do |
| 25 |
|
{:ok, parsed_xml} -> |
| 26 |
:-( |
case get_transaction_from_check_request(parsed_xml) do |
| 27 |
|
{:ok, transaction} -> |
| 28 |
:-( |
case build_check_transaction_response(transaction, parsed_xml) do |
| 29 |
|
{:ok, response_xml} -> |
| 30 |
|
conn |
| 31 |
|
|> put_resp_content_type("application/xml") |
| 32 |
:-( |
|> send_resp(200, response_xml) |
| 33 |
|
|
| 34 |
|
{:error, reason} -> |
| 35 |
:-( |
send_error_response(conn, parsed_xml, "U30", "Processing error: #{inspect(reason)}") |
| 36 |
|
end |
| 37 |
|
|
| 38 |
|
{:error, :transaction_not_found} -> |
| 39 |
:-( |
send_error_response(conn, parsed_xml, "U16", "Transaction not found") |
| 40 |
|
end |
| 41 |
|
|
| 42 |
|
{:error, reason} -> |
| 43 |
:-( |
send_error_response(conn, nil, "U30", "XML parsing error: #{inspect(reason)}") |
| 44 |
|
end |
| 45 |
|
end |
| 46 |
|
|
| 47 |
|
@doc """ |
| 48 |
|
Handle NPCI ReqPay (REVERSAL) - Process reversal request |
| 49 |
|
Called by NPCI if check transaction times out or fails |
| 50 |
|
""" |
| 51 |
|
def process_reversal(conn, %{"xml" => xml_string}) do |
| 52 |
:-( |
case parse_simple_xml(xml_string) do |
| 53 |
|
{:ok, parsed_xml} -> |
| 54 |
:-( |
case get_transaction_from_reversal_request(parsed_xml) do |
| 55 |
|
{:ok, transaction} -> |
| 56 |
:-( |
case process_transaction_reversal(transaction) do |
| 57 |
|
{:ok, reversal_result} -> |
| 58 |
:-( |
case build_reversal_response(reversal_result, parsed_xml) do |
| 59 |
|
{:ok, response_xml} -> |
| 60 |
|
conn |
| 61 |
|
|> put_resp_content_type("application/xml") |
| 62 |
:-( |
|> send_resp(200, response_xml) |
| 63 |
|
|
| 64 |
|
{:error, reason} -> |
| 65 |
:-( |
send_error_response(conn, parsed_xml, "U30", "Response building error: #{inspect(reason)}") |
| 66 |
|
end |
| 67 |
|
|
| 68 |
|
{:error, reason} -> |
| 69 |
:-( |
send_error_response(conn, parsed_xml, "U30", "Reversal processing error: #{inspect(reason)}") |
| 70 |
|
end |
| 71 |
|
|
| 72 |
|
{:error, :transaction_not_found} -> |
| 73 |
:-( |
send_error_response(conn, parsed_xml, "U16", "Transaction not found for reversal") |
| 74 |
|
end |
| 75 |
|
|
| 76 |
|
{:error, reason} -> |
| 77 |
:-( |
send_error_response(conn, nil, "U30", "XML parsing error: #{inspect(reason)}") |
| 78 |
|
end |
| 79 |
|
end |
| 80 |
|
|
| 81 |
|
@doc """ |
| 82 |
|
Handle NPCI ReqHbt - Heartbeat check |
| 83 |
|
Confirms our system is operational |
| 84 |
|
""" |
| 85 |
|
def heartbeat(conn, %{"xml" => xml_string}) do |
| 86 |
:-( |
case parse_simple_xml(xml_string) do |
| 87 |
|
{:ok, parsed_xml} -> |
| 88 |
:-( |
case build_heartbeat_response(parsed_xml) do |
| 89 |
|
{:ok, response_xml} -> |
| 90 |
|
conn |
| 91 |
|
|> put_resp_content_type("application/xml") |
| 92 |
:-( |
|> send_resp(200, response_xml) |
| 93 |
|
|
| 94 |
|
{:error, _reason} -> |
| 95 |
|
# Even on parsing error, we should respond to show we're alive |
| 96 |
:-( |
basic_heartbeat_response = """ |
| 97 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 98 |
|
<upi:RespHbt xmlns:upi="http://npci.org/upi/schema/"> |
| 99 |
:-( |
<Head ver="2.0" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" orgId="MERCURYPSP" msgId="HBT#{:rand.uniform(999999)}"/> |
| 100 |
:-( |
<Resp reqMsgId="#{parsed_xml.header.msg_id}" result="SUCCESS" errCode=""/> |
| 101 |
|
</upi:RespHbt> |
| 102 |
|
""" |
| 103 |
|
|
| 104 |
|
conn |
| 105 |
|
|> put_resp_content_type("application/xml") |
| 106 |
:-( |
|> send_resp(200, basic_heartbeat_response) |
| 107 |
|
end |
| 108 |
|
|
| 109 |
|
{:error, _reason} -> |
| 110 |
|
# Even on parsing error, we should respond to show we're alive |
| 111 |
:-( |
basic_heartbeat_response = """ |
| 112 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 113 |
|
<upi:RespHbt xmlns:upi="http://npci.org/upi/schema/"> |
| 114 |
:-( |
<Head ver="2.0" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" orgId="MERCURYPSP" msgId="HBT#{:rand.uniform(999999)}"/> |
| 115 |
|
<Resp reqMsgId="UNKNOWN" result="SUCCESS" errCode=""/> |
| 116 |
|
</upi:RespHbt> |
| 117 |
|
""" |
| 118 |
|
|
| 119 |
|
conn |
| 120 |
|
|> put_resp_content_type("application/xml") |
| 121 |
:-( |
|> send_resp(200, basic_heartbeat_response) |
| 122 |
|
end |
| 123 |
|
end |
| 124 |
|
|
| 125 |
|
# Simple XML parser for NPCI requests (placeholder until full UpiXmlParser is implemented) |
| 126 |
|
defp parse_simple_xml(xml_string) do |
| 127 |
:-( |
try do |
| 128 |
|
# Extract orgTxnId using regex for now |
| 129 |
:-( |
org_txn_id = case Regex.run(~r/orgTxnId="([^"]+)"/, xml_string) do |
| 130 |
:-( |
[_, id] -> id |
| 131 |
:-( |
_ -> "UNKNOWN" |
| 132 |
|
end |
| 133 |
|
|
| 134 |
|
# Extract msgId |
| 135 |
:-( |
msg_id = case Regex.run(~r/msgId="([^"]+)"/, xml_string) do |
| 136 |
:-( |
[_, id] -> id |
| 137 |
:-( |
_ -> "UNKNOWN" |
| 138 |
|
end |
| 139 |
|
|
| 140 |
|
# Extract type |
| 141 |
:-( |
type = cond do |
| 142 |
:-( |
String.contains?(xml_string, "ReqChkTxn") -> "ChkTxn" |
| 143 |
:-( |
String.contains?(xml_string, "ReqPay") && String.contains?(xml_string, "REVERSAL") -> "REVERSAL" |
| 144 |
:-( |
String.contains?(xml_string, "ReqHbt") -> "Heartbeat" |
| 145 |
:-( |
true -> "UNKNOWN" |
| 146 |
|
end |
| 147 |
|
|
| 148 |
:-( |
parsed = %{ |
| 149 |
|
header: %{msg_id: msg_id}, |
| 150 |
|
transaction: %{ |
| 151 |
|
org_txn_id: org_txn_id, |
| 152 |
|
type: type, |
| 153 |
|
ref_id: "REF123", |
| 154 |
|
cust_ref: "CUST123" |
| 155 |
|
} |
| 156 |
|
} |
| 157 |
|
|
| 158 |
|
{:ok, parsed} |
| 159 |
|
rescue |
| 160 |
:-( |
_ -> {:error, "XML parsing failed"} |
| 161 |
|
end |
| 162 |
|
end |
| 163 |
|
|
| 164 |
|
# Private helper functions |
| 165 |
|
|
| 166 |
|
defp get_transaction_from_check_request(parsed_xml) do |
| 167 |
:-( |
org_txn_id = parsed_xml.transaction.org_txn_id |
| 168 |
|
|
| 169 |
:-( |
case UpiInternationalService.get_transaction_by_org_id(org_txn_id) do |
| 170 |
:-( |
nil -> {:error, :transaction_not_found} |
| 171 |
:-( |
transaction -> {:ok, transaction} |
| 172 |
|
end |
| 173 |
|
end |
| 174 |
|
|
| 175 |
|
defp get_transaction_from_reversal_request(parsed_xml) do |
| 176 |
:-( |
org_txn_id = parsed_xml.transaction.org_txn_id |
| 177 |
|
|
| 178 |
:-( |
case UpiInternationalService.get_transaction_by_org_id(org_txn_id) do |
| 179 |
:-( |
nil -> {:error, :transaction_not_found} |
| 180 |
:-( |
transaction -> {:ok, transaction} |
| 181 |
|
end |
| 182 |
|
end |
| 183 |
|
|
| 184 |
|
defp process_transaction_reversal(transaction) do |
| 185 |
:-( |
case transaction.current_state do |
| 186 |
|
"credit_pending" -> |
| 187 |
|
# Confirm reversal - we haven't credited partner yet |
| 188 |
:-( |
UpiInternationalService.handle_partner_reversal(transaction) |
| 189 |
|
{:ok, %{status: "REVERSAL_CONFIRMED", resp_code: "00"}} |
| 190 |
|
|
| 191 |
:-( |
"success" -> |
| 192 |
|
# Confirm credit success - transaction already completed |
| 193 |
|
{:ok, %{status: "CREDIT_SUCCESS", resp_code: "CS"}} |
| 194 |
|
|
| 195 |
:-( |
state when state in ["failure", "reversed", "deemed"] -> |
| 196 |
|
# Transaction already failed/reversed |
| 197 |
|
{:ok, %{status: "ALREADY_REVERSED", resp_code: "00"}} |
| 198 |
|
|
| 199 |
|
_ -> |
| 200 |
|
# Unknown state - safe to reverse |
| 201 |
:-( |
UpiInternationalService.handle_partner_reversal(transaction) |
| 202 |
|
{:ok, %{status: "REVERSAL_CONFIRMED", resp_code: "00"}} |
| 203 |
|
end |
| 204 |
|
end |
| 205 |
|
|
| 206 |
|
defp build_check_transaction_response(transaction, parsed_xml) do |
| 207 |
|
# Determine response based on transaction state |
| 208 |
:-( |
{result, resp_code, _status} = case transaction.current_state do |
| 209 |
:-( |
"success" -> {"SUCCESS", "CS", "SUCCESS"} |
| 210 |
:-( |
"failure" -> {"FAILURE", transaction.failure_code || "U30", "FAILURE"} |
| 211 |
:-( |
"deemed" -> {"DEEMED", "U30", "DEEMED"} |
| 212 |
:-( |
"reversed" -> {"FAILURE", "00", "FAILURE"} |
| 213 |
:-( |
_ -> {"PENDING", "", "PENDING"} # Still processing |
| 214 |
|
end |
| 215 |
|
|
| 216 |
:-( |
xml = """ |
| 217 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 218 |
|
<upi:RespChkTxn xmlns:upi="http://npci.org/upi/schema/"> |
| 219 |
:-( |
<Head ver="2.0" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" orgId="MERCURYPSP" msgId="CHK#{:rand.uniform(999999)}"/> |
| 220 |
:-( |
<Txn id="#{transaction.org_txn_id}" note="International payment" refId="#{parsed_xml.transaction.ref_id || ""}" |
| 221 |
:-( |
refUrl="" refCategory="" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" custRef="#{parsed_xml.transaction.cust_ref || ""}" |
| 222 |
:-( |
type="ChkTxn" orgMsgId="#{parsed_xml.header.msg_id}" orgTxnId="#{transaction.org_txn_id}" |
| 223 |
:-( |
orgTxnDate="#{transaction.inserted_at |> DateTime.to_iso8601()}" initiationMode="00" purpose="11" subType="CREDIT"/> |
| 224 |
:-( |
<Resp reqMsgId="#{parsed_xml.header.msg_id}" result="#{result}" errCode=""> |
| 225 |
:-( |
<Ref type="PAYEE" seqNum="1" addr="#{transaction.payee_addr}" code="#{transaction.payee_mid}" |
| 226 |
:-( |
orgAmount="#{transaction.foreign_amount}" respCode="#{resp_code}" regName="#{transaction.payee_name}" |
| 227 |
:-( |
IFSC="" acNum="" accType="" approvalNum="#{transaction.partner_txn_id || ""}" |
| 228 |
:-( |
settAmount="#{transaction.foreign_amount}" settCurrency="#{transaction.foreign_currency}"/> |
| 229 |
|
</Resp> |
| 230 |
|
</upi:RespChkTxn> |
| 231 |
|
""" |
| 232 |
|
|
| 233 |
|
{:ok, xml} |
| 234 |
|
end |
| 235 |
|
|
| 236 |
|
defp build_reversal_response(reversal_result, parsed_xml) do |
| 237 |
:-( |
xml = """ |
| 238 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 239 |
|
<upi:RespPay xmlns:upi="http://npci.org/upi/schema/"> |
| 240 |
:-( |
<Head ver="2.0" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" orgId="MERCURYPSP" msgId="REV#{:rand.uniform(999999)}"/> |
| 241 |
:-( |
<Txn id="#{parsed_xml.transaction.id}" note="Reversal processed" refId="#{parsed_xml.transaction.ref_id || ""}" |
| 242 |
:-( |
refUrl="" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" type="REVERSAL" |
| 243 |
:-( |
orgTxnId="#{parsed_xml.transaction.org_txn_id}" initiationMode="00" purpose="11" custRef="#{parsed_xml.transaction.cust_ref || ""}"/> |
| 244 |
:-( |
<Resp reqMsgId="#{parsed_xml.header.msg_id}" result="SUCCESS" errCode=""> |
| 245 |
:-( |
<Ref type="PAYEE" seqNum="1" addr="" code="" orgAmount="" respCode="#{reversal_result.resp_code}" |
| 246 |
|
regName="" IFSC="" acNum="" accType="" approvalNum="" settAmount="" settCurrency=""/> |
| 247 |
|
</Resp> |
| 248 |
|
</upi:RespPay> |
| 249 |
|
""" |
| 250 |
|
|
| 251 |
|
{:ok, xml} |
| 252 |
|
end |
| 253 |
|
|
| 254 |
|
defp build_heartbeat_response(parsed_xml) do |
| 255 |
:-( |
xml = """ |
| 256 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 257 |
|
<upi:RespHbt xmlns:upi="http://npci.org/upi/schema/"> |
| 258 |
:-( |
<Head ver="2.0" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" orgId="MERCURYPSP" msgId="HBT#{:rand.uniform(999999)}"/> |
| 259 |
:-( |
<Resp reqMsgId="#{parsed_xml.header.msg_id}" result="SUCCESS" errCode=""/> |
| 260 |
|
</upi:RespHbt> |
| 261 |
|
""" |
| 262 |
|
|
| 263 |
|
{:ok, xml} |
| 264 |
|
end |
| 265 |
|
|
| 266 |
|
defp send_error_response(conn, parsed_xml, error_code, _error_message) do |
| 267 |
:-( |
msg_id = if parsed_xml && parsed_xml.header, do: parsed_xml.header.msg_id, else: "UNKNOWN" |
| 268 |
|
|
| 269 |
:-( |
xml = """ |
| 270 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 271 |
|
<upi:RespChkTxn xmlns:upi="http://npci.org/upi/schema/"> |
| 272 |
:-( |
<Head ver="2.0" ts="#{DateTime.utc_now() |> DateTime.to_iso8601()}" orgId="MERCURYPSP" msgId="ERR#{:rand.uniform(999999)}"/> |
| 273 |
:-( |
<Resp reqMsgId="#{msg_id}" result="FAILURE" errCode="#{error_code}"/> |
| 274 |
|
</upi:RespChkTxn> |
| 275 |
|
""" |
| 276 |
|
|
| 277 |
|
conn |
| 278 |
|
|> put_resp_content_type("application/xml") |
| 279 |
:-( |
|> send_resp(400, xml) |
| 280 |
|
end |
| 281 |
|
end |