| 1 |
|
defmodule DaProductAppWeb.UpiXmlSchema do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Complete UPI XML Schema handler as per NPCI specification. |
| 4 |
|
Handles parsing and generation of all UPI XML messages with full field validation. |
| 5 |
|
Uses SweetXML for robust XML parsing with proper namespace support. |
| 6 |
|
""" |
| 7 |
|
|
| 8 |
|
import SweetXml |
| 9 |
|
alias Timex |
| 10 |
|
|
| 11 |
|
# UPI Standard Error Codes as per specification |
| 12 |
|
@upi_error_codes %{ |
| 13 |
|
"00" => "Transaction is successful", |
| 14 |
|
"01" => "Transaction is pending", |
| 15 |
|
"02" => "Transaction failed", |
| 16 |
|
"03" => "Transaction timeout", |
| 17 |
|
"04" => "Format Error in transaction", |
| 18 |
|
"05" => "Invalid transaction", |
| 19 |
|
"06" => "Amount limit exceeded", |
| 20 |
|
"07" => "Permission denied", |
| 21 |
|
"08" => "Invalid response", |
| 22 |
|
"09" => "Collect request expired", |
| 23 |
|
"10" => "Debit has been failed", |
| 24 |
|
"11" => "Credit has been failed", |
| 25 |
|
"12" => "UPI PIN has not been set by customer", |
| 26 |
|
"13" => "Transaction not allowed to this account", |
| 27 |
|
"14" => "External Error", |
| 28 |
|
"15" => "Request declined by user", |
| 29 |
|
"16" => "Risk threshold exceeded", |
| 30 |
|
"17" => "Requester cannot perform this operation", |
| 31 |
|
"18" => "Required field missing", |
| 32 |
|
"19" => "Address resolution failed", |
| 33 |
|
"20" => "Credit reversal timeout", |
| 34 |
|
"21" => "Debit reversal timeout", |
| 35 |
|
"22" => "Remitter CBS offline", |
| 36 |
|
"23" => "Beneficiary CBS offline", |
| 37 |
|
"24" => "Transaction not permitted to Payee", |
| 38 |
|
"25" => "Transaction not permitted to Payer", |
| 39 |
|
"26" => "Invalid amount", |
| 40 |
|
"27" => "Expired Card/VPA/Account", |
| 41 |
|
"28" => "Transaction not allowed to Terminal", |
| 42 |
|
"29" => "Hold limit exceeded", |
| 43 |
|
"30" => "Notional limit exceeded", |
| 44 |
|
"31" => "Fraud Transaction Blocked", |
| 45 |
|
"32" => "Collect Request Declined", |
| 46 |
|
"33" => "PSP is not available", |
| 47 |
|
"34" => "PSP not enabled for merchant", |
| 48 |
|
"35" => "Merchant transaction limit exceeded", |
| 49 |
|
"36" => "Customer transaction limit exceeded", |
| 50 |
|
"37" => "Merchant daily transaction limit exceeded", |
| 51 |
|
"38" => "Customer daily transaction limit exceeded", |
| 52 |
|
"39" => "PSP transaction limit exceeded", |
| 53 |
|
"40" => "Transaction frequency limit exceeded", |
| 54 |
|
"91" => "Invalid Parameters", |
| 55 |
|
"92" => "No such mobile/account", |
| 56 |
|
"93" => "Validation Error", |
| 57 |
|
"94" => "Beneficiary bank unavailable", |
| 58 |
|
"95" => "Issuer or switch inoperative", |
| 59 |
|
"96" => "System malfunction", |
| 60 |
|
"97" => "Timeout at Acquirer end", |
| 61 |
|
"98" => "Duplicate transaction", |
| 62 |
|
"99" => "Request could not be delivered", |
| 63 |
|
"ZH" => "Invalid XML", |
| 64 |
|
"ZM" => "Checksum failed", |
| 65 |
|
"ZP" => "PSP OrgId not found", |
| 66 |
|
"ZQ" => "QR code not found", |
| 67 |
|
"ZR" => "Merchant not found" |
| 68 |
|
} |
| 69 |
|
|
| 70 |
|
@doc """ |
| 71 |
|
Parse ReqValQR XML with full UPI specification compliance |
| 72 |
|
Parses NPCI standard QR validation request with namespace support |
| 73 |
|
""" |
| 74 |
|
def parse_req_val_qr(xml_string) do |
| 75 |
:-( |
try do |
| 76 |
:-( |
with {:ok, data} <- extract_req_val_qr_data(xml_string), |
| 77 |
:-( |
{:ok, fields} <- validate_req_val_qr_fields(data) do |
| 78 |
|
{:ok, fields} |
| 79 |
|
else |
| 80 |
:-( |
error -> error |
| 81 |
|
end |
| 82 |
|
rescue |
| 83 |
:-( |
e -> {:error, "XML parsing failed: #{inspect(e)}"} |
| 84 |
|
end |
| 85 |
|
end |
| 86 |
|
|
| 87 |
|
@doc """ |
| 88 |
|
Generate RespValQR XML response with proper NPCI namespace and structure |
| 89 |
|
Fully compliant with NPCI specification including all mandatory fields |
| 90 |
|
""" |
| 91 |
|
def generate_resp_val_qr(response_data) do |
| 92 |
|
require Logger |
| 93 |
|
|
| 94 |
|
# Extract merchant type with debugging |
| 95 |
:-( |
merchant_type = case Map.get(response_data, :merchant_type) do |
| 96 |
|
nil -> |
| 97 |
:-( |
extracted_type = extract_merchant_type_from_qr(response_data) |
| 98 |
:-( |
Logger.info("Merchant type extracted from QR: #{extracted_type}") |
| 99 |
:-( |
extracted_type || "SMALL" |
| 100 |
|
existing_type when existing_type != "" -> |
| 101 |
:-( |
Logger.info("Using provided merchant type: #{existing_type}") |
| 102 |
:-( |
existing_type |
| 103 |
|
_ -> |
| 104 |
:-( |
extracted_type = extract_merchant_type_from_qr(response_data) |
| 105 |
:-( |
Logger.info("Merchant type extracted from QR (fallback): #{extracted_type}") |
| 106 |
:-( |
extracted_type || "SMALL" |
| 107 |
|
end |
| 108 |
|
|
| 109 |
:-( |
Logger.info("Final merchant type for XML: #{merchant_type}") |
| 110 |
:-( |
Logger.info("QR payload in response_data: #{inspect(Map.get(response_data, :qr_payload))}") |
| 111 |
|
|
| 112 |
:-( |
xml = """ |
| 113 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 114 |
|
<ns2:RespValQr xmlns:ns2="http://npci.org/upi/schema/"> |
| 115 |
:-( |
<Head ver="2.0" ts="#{get_timestamp()}" orgId="#{"MER101"}" msgId="#{generate_fixed_length_msg_id(Map.get(response_data, :msg_id))}"/> |
| 116 |
:-( |
<Resp reqMsgId="#{Map.get(response_data, :req_msg_id)}" result="#{Map.get(response_data, :result, "SUCCESS")}"#{if Map.get(response_data, :err_code) && Map.get(response_data, :err_code) != "00", do: " errCode=\"#{Map.get(response_data, :err_code)}\"", else: ""}/> |
| 117 |
:-( |
<Txn id="#{Map.get(response_data, :txn_id)}" note="#{validate_and_preserve_note(Map.get(response_data, :note))}" refId="#{Map.get(response_data, :ref_id)}" refUrl="#{Map.get(response_data, :ref_url, "https://mercurypay.ariticapp.com")}" ts="#{Map.get(response_data, :txn_timestamp) || get_timestamp()}" type="IntlQr" custRef="#{format_cust_ref(Map.get(response_data, :cust_ref))}" initiationMode="#{Map.get(response_data, :initiation_mode)}" purpose="#{format_purpose(Map.get(response_data, :purpose))}">> |
| 118 |
:-( |
<QR qVer="#{Map.get(response_data, :qr_version, "02")}" qrMedium="#{Map.get(response_data, :qr_medium, "03")}" expireTs="#{Map.get(response_data, :expire_ts) || get_expiry_timestamp()}"/> |
| 119 |
|
</Txn> |
| 120 |
:-( |
<Payee addr="#{extract_vpa_from_qr_string(Map.get(response_data, :qr_payload)) || Map.get(response_data, :payee_addr)}" name="#{Map.get(response_data, :payee_name)}" seqNum="1" type="#{Map.get(response_data, :payee_type, "ENTITY")}" code="#{Map.get(response_data, :sub_code) || Map.get(response_data, :merchant_code, "0000")}"> |
| 121 |
|
<Ac addrType="ACCOUNT"> |
| 122 |
:-( |
<Detail name="ACTYPE" value="#{Map.get(response_data, :account_type, "SAVINGS")}"/> |
| 123 |
:-( |
<Detail name="ACNUM" value="#{Map.get(response_data, :account_number, "1234567890")}"/> |
| 124 |
:-( |
<Detail name="IFSC" value="#{Map.get(response_data, :ifsc_code, "MERC0000001")}"/> |
| 125 |
|
</Ac> |
| 126 |
:-( |
<Amount value="#{Map.get(response_data, :amount)}" curr="#{Map.get(response_data, :currency)}"/> |
| 127 |
|
<Merchant> |
| 128 |
:-( |
<Identifier subCode="#{Map.get(response_data, :sub_code) || Map.get(response_data, :merchant_code, "0000")}" mid="#{extract_mid_from_qr(response_data) || Map.get(response_data, :merchant_id)}" sid="#{extract_msid_from_qr(response_data) || Map.get(response_data, :store_id, "STORE001")}" tid="#{extract_mtid_from_qr(response_data) || Map.get(response_data, :terminal_id, "TERM001")}" merchantType="#{merchant_type}" merchantGenre="#{Map.get(response_data, :merchant_genre) || extract_merchant_genre_from_qr(response_data) || "OFFLINE"}" onBoardingType="#{Map.get(response_data, :onboarding_type, "AGGREGATOR")}" merchantLoc="#{extract_merchant_location_from_qr(response_data) || "IN"}"/> |
| 129 |
:-( |
<Name brand="#{Map.get(response_data, :merchant_brand) || Map.get(response_data, :payee_name)}"/> |
| 130 |
:-( |
<Ownership type="#{Map.get(response_data, :ownership_type, "PARTNERSHIP")}"/> |
| 131 |
:-( |
<Invoice name="#{Map.get(response_data, :invoice_name) || Map.get(response_data, :payee_name)}" num="#{extract_invoice_number_from_qr(response_data) || Map.get(response_data, :invoice_number) || generate_invoice_number()}" date="#{Map.get(response_data, :invoice_date) || get_timestamp()}"/> |
| 132 |
|
</Merchant> |
| 133 |
:-( |
<Institution netInstId="#{Map.get(response_data, :net_inst_id, "MER1010001")}" QrPayLoad="#{escape_xml_entities(Map.get(response_data, :qr_payload, ""))}" conCode="#{Map.get(response_data, :country_code, "IN")}"/> |
| 134 |
|
<FxList> |
| 135 |
:-( |
<Fx baseAmount="#{Map.get(response_data, :base_amount) || Map.get(response_data, :amount)}" baseCurr="#{Map.get(response_data, :base_currency) || Map.get(response_data, :currency)}"/> |
| 136 |
|
</FxList> |
| 137 |
|
</Payee> |
| 138 |
|
</ns2:RespValQr> |
| 139 |
|
""" |
| 140 |
|
|
| 141 |
:-( |
Logger.info("Generated XML with merchantType: #{merchant_type}") |
| 142 |
|
{:ok, xml} |
| 143 |
|
end |
| 144 |
|
|
| 145 |
|
@doc """ |
| 146 |
|
Parse International Credit Request (ReqPay) XML with full UPI specification compliance |
| 147 |
|
Handles enhanced international UPI structure with merchant details, risk scores, and device info |
| 148 |
|
""" |
| 149 |
|
def parse_req_pay(xml_string) do |
| 150 |
:-( |
try do |
| 151 |
:-( |
with {:ok, data} <- extract_international_req_pay_data(xml_string), |
| 152 |
:-( |
{:ok, fields} <- validate_international_req_pay_fields(data) do |
| 153 |
|
{:ok, fields} |
| 154 |
|
else |
| 155 |
:-( |
error -> error |
| 156 |
|
end |
| 157 |
|
rescue |
| 158 |
:-( |
e -> {:error, "XML parsing failed: #{inspect(e)}"} |
| 159 |
|
end |
| 160 |
|
end |
| 161 |
|
|
| 162 |
|
@doc """ |
| 163 |
|
Generate International Credit Response (RespPay) XML response |
| 164 |
|
Follows NPCI 2.0 specification with settlement details and approval codes |
| 165 |
|
""" |
| 166 |
|
def generate_resp_pay(response_data) do |
| 167 |
|
# Build the core XML body without the XMLDSIG Signature and without duplicating the closing tag |
| 168 |
|
# Prefer values from the original request payload if present (key :request_data or :request) |
| 169 |
:-( |
request_source = Map.get(response_data, :request_data) || Map.get(response_data, :request) || %{} |
| 170 |
|
|
| 171 |
|
# Ensure Txn id: prefer the original ReqPay Txn id verbatim when available |
| 172 |
|
# NPCI expects the RespPay Txn.id to match the ReqPay Txn.id. We take the |
| 173 |
|
# request's txn id as-is (safely truncated to 35 chars) and only generate a |
| 174 |
|
# fallback if none is present. |
| 175 |
|
# Helper to fetch values from request_source or response_data using |
| 176 |
|
# common atom/string key variants to be robust against parsers that return |
| 177 |
|
# either atoms or string keys. |
| 178 |
:-( |
fetch_val = fn keys_list, fallback -> |
| 179 |
|
Enum.find_value(keys_list, fn key -> |
| 180 |
:-( |
case key do |
| 181 |
:-( |
k when is_atom(k) -> Map.get(request_source, k) || Map.get(response_data, k) |
| 182 |
:-( |
k when is_binary(k) -> Map.get(request_source, k) || Map.get(response_data, k) |
| 183 |
|
end |
| 184 |
:-( |
end) || fallback |
| 185 |
|
end |
| 186 |
|
|
| 187 |
|
# Ensure Txn id: prefer the original ReqPay Txn id verbatim when available. |
| 188 |
|
# NPCI expects the RespPay Txn.id to match the ReqPay Txn.id exactly. |
| 189 |
:-( |
raw_txn_id = fetch_val.([:txn_id, "txn_id", :id, "id", :org_txn_id, "org_txn_id"], nil) |
| 190 |
:-( |
txn_id = |
| 191 |
|
case raw_txn_id do |
| 192 |
:-( |
id when is_binary(id) and id != "" -> String.slice(String.trim(id), 0, 35) |
| 193 |
:-( |
id when is_integer(id) -> to_string(id) |
| 194 |
:-( |
_ -> generate_msg_id_like_sample() |
| 195 |
|
end |
| 196 |
|
|
| 197 |
|
# Prefer QR attributes from the original request to avoid NPCI mismatches |
| 198 |
|
# CRITICAL: NEVER generate default expiry timestamp for RespPay - must match original ReqPay exactly |
| 199 |
:-( |
qr_expire_ts = case Map.get(request_source, :expire_ts) || Map.get(response_data, :expire_ts) do |
| 200 |
|
nil -> |
| 201 |
|
require Logger |
| 202 |
:-( |
Logger.error("CRITICAL ERROR: QR ExpireTs missing from original ReqPay request. This will cause NPCI rejection 'CZ:TXN QR ExpireTs Not Match'") |
| 203 |
:-( |
Logger.error("Request source keys: #{inspect(Map.keys(request_source))}") |
| 204 |
:-( |
Logger.error("Response data keys: #{inspect(Map.keys(response_data))}") |
| 205 |
:-( |
Logger.error("Request source expire_ts: #{inspect(Map.get(request_source, :expire_ts))}") |
| 206 |
:-( |
Logger.error("Response data expire_ts: #{inspect(Map.get(response_data, :expire_ts))}") |
| 207 |
|
# Fallback but log the error |
| 208 |
:-( |
generate_default_qr_expire_ts() |
| 209 |
:-( |
expire_ts -> expire_ts |
| 210 |
|
end |
| 211 |
:-( |
qr_ver = Map.get(request_source, :qr_ver) || Map.get(response_data, :qr_ver) || generate_default_qr_ver() |
| 212 |
:-( |
qr_medium = Map.get(request_source, :qr_medium) || Map.get(response_data, :qr_medium) || generate_default_qr_medium() |
| 213 |
|
|
| 214 |
|
# CRITICAL: QR timestamp must match the original QR element structure for NPCI compliance |
| 215 |
|
# If original QR had no ts attribute, don't include it in response; if it had ts, match it exactly |
| 216 |
|
# Priority: 1) Match original QR structure, 2) Transaction timestamp, 3) QRts from payload, 4) Generated |
| 217 |
:-( |
original_qr_ts = Map.get(request_source, :qr_ts) |
| 218 |
|
|
| 219 |
:-( |
qr_ts_attribute = if original_qr_ts && original_qr_ts != "" do |
| 220 |
|
# Original QR had ts attribute, use transaction timestamp to match NPCI expectation |
| 221 |
:-( |
Map.get(request_source, :txn_ts) || original_qr_ts || extract_qr_ts_from_payload(request_source) |
| 222 |
|
else |
| 223 |
|
# Original QR had no ts attribute, don't include ts in response |
| 224 |
|
nil |
| 225 |
|
end |
| 226 |
|
|
| 227 |
|
# QR verToken: Use original verToken from request if available, otherwise generate default |
| 228 |
|
# NPCI expects RespPay QR element to mirror original ReqPay QR element structure |
| 229 |
:-( |
original_ver_token = Map.get(request_source, :ver_token) |
| 230 |
:-( |
qr_ver_token = if original_ver_token && original_ver_token != "" do |
| 231 |
:-( |
original_ver_token |
| 232 |
|
else |
| 233 |
:-( |
Map.get(request_source, :qr_ver_token) || Map.get(response_data, :qr_ver_token) || generate_default_qr_ver_token() |
| 234 |
|
end |
| 235 |
|
|
| 236 |
|
# Only include verToken attribute if it was present in the original request |
| 237 |
:-( |
qr_ver_token_attribute = if original_ver_token && original_ver_token != "", do: qr_ver_token, else: nil |
| 238 |
|
|
| 239 |
|
require Logger |
| 240 |
:-( |
Logger.info("QR ExpireTs calculation - request_source.expire_ts: #{inspect(Map.get(request_source, :expire_ts))}") |
| 241 |
:-( |
Logger.info("QR ExpireTs calculation - response_data.expire_ts: #{inspect(Map.get(response_data, :expire_ts))}") |
| 242 |
:-( |
Logger.info("QR ExpireTs calculation - final qr_expire_ts: #{inspect(qr_expire_ts)}") |
| 243 |
|
|
| 244 |
|
# CRITICAL: Log orgTxnId mapping to debug OR2 errors |
| 245 |
:-( |
original_txn_id = Map.get(request_source, :txn_id) |
| 246 |
:-( |
fallback_txn_id = Map.get(response_data, :txn_id) || Map.get(response_data, :org_txn_id) |
| 247 |
:-( |
final_org_txn_id = original_txn_id || fallback_txn_id || "" |
| 248 |
:-( |
Logger.info("OrgTxnId calculation - request_source.txn_id: #{inspect(original_txn_id)}") |
| 249 |
:-( |
Logger.info("OrgTxnId calculation - response_data.txn_id: #{inspect(Map.get(response_data, :txn_id))}") |
| 250 |
:-( |
Logger.info("OrgTxnId calculation - response_data.org_txn_id: #{inspect(Map.get(response_data, :org_txn_id))}") |
| 251 |
:-( |
Logger.info("OrgTxnId calculation - final orgTxnId: #{inspect(final_org_txn_id)}") |
| 252 |
|
|
| 253 |
:-( |
Logger.info("QR timestamp calculation - original_qr_ts: #{inspect(original_qr_ts)}") |
| 254 |
:-( |
Logger.info("QR timestamp calculation - request_source.txn_ts: #{inspect(Map.get(request_source, :txn_ts))}") |
| 255 |
:-( |
Logger.info("QR timestamp calculation - QRts from payload: #{inspect(extract_qr_ts_from_payload(request_source))}") |
| 256 |
:-( |
Logger.info("QR timestamp calculation - final qr_ts_attribute: #{inspect(qr_ts_attribute)}") |
| 257 |
|
|
| 258 |
:-( |
Logger.info("QR verToken calculation - original_ver_token: #{inspect(original_ver_token)}") |
| 259 |
:-( |
Logger.info("QR verToken calculation - qr_ver_token fallback: #{inspect(Map.get(request_source, :qr_ver_token) || Map.get(response_data, :qr_ver_token))}") |
| 260 |
:-( |
Logger.info("QR verToken calculation - final qr_ver_token: #{inspect(qr_ver_token)}") |
| 261 |
:-( |
Logger.info("QR verToken calculation - conditional qr_ver_token_attribute: #{inspect(qr_ver_token_attribute)}") |
| 262 |
|
|
| 263 |
:-( |
qr_query = Map.get(request_source, :qr_query) || Map.get(response_data, :qr_query) || generate_default_qr_query() |
| 264 |
|
|
| 265 |
|
# # CRITICAL: STAN must be extracted from original request to avoid NPCI STAN MISMATCH |
| 266 |
|
# stan = Map.get(request_source, :stan) || Map.get(response_data, :stan) || |
| 267 |
|
# extract_stan_from_qr_payload(Map.get(request_source, :qr_payload) || Map.get(response_data, :qr_payload)) || |
| 268 |
|
# generate_deterministic_stan(Map.get(request_source, :txn_id) || Map.get(response_data, :txn_id)) |
| 269 |
|
|
| 270 |
|
# Ensure we always emit a Resp.ErrorCode element (NPCI mandates RESP.ERRORCODE) |
| 271 |
:-( |
err_code = Map.get(response_data, :err_code) || Map.get(response_data, :errCode) || |
| 272 |
:-( |
(if Map.get(response_data, :result) == "SUCCESS", do: "00", else: "02") |
| 273 |
|
|
| 274 |
|
# Sanitize mandatory Ref fields to meet NPCI validation: |
| 275 |
|
# seqNum must be numeric; addr must be alphanumeric; approvalNum must be present |
| 276 |
:-( |
ref_seq_raw = Map.get(response_data, :seq_num) || Map.get(request_source, :seq_num) || "" |
| 277 |
:-( |
ref_seq = ref_seq_raw |> to_string() |> String.replace(~r/[^0-9]/, "") |
| 278 |
:-( |
ref_seq = if ref_seq == "", do: generate_numeric_seq_num(), else: ref_seq |
| 279 |
|
|
| 280 |
:-( |
payee_addr_raw = Map.get(response_data, :payee_addr) || Map.get(request_source, :payee_addr) || Map.get(response_data, :addr) || "" |
| 281 |
:-( |
payee_addr = payee_addr_raw |> to_string() |> String.replace(~r/[^A-Za-z0-9]/, "") |
| 282 |
:-( |
payee_addr = if payee_addr == "", do: (Map.get(response_data, :ac_num) || Map.get(request_source, :ac_num) || "") |> to_string() |> String.replace(~r/[^A-Za-z0-9]/, ""), else: payee_addr |
| 283 |
|
|
| 284 |
:-( |
approval_num_final = Map.get(response_data, :approval_num) || Map.get(request_source, :approval_num) || generate_approval_num() |
| 285 |
|
|
| 286 |
|
# Determine Resp.reqMsgId: must equal the original ReqPay Head.msgId |
| 287 |
|
# NPCI expects Resp.reqMsgId == ReqPay.Head.msgId. Try common key names. |
| 288 |
:-( |
resp_req_msg_id = fetch_val.([:req_msg_id, "req_msg_id", :reqMsgId, "reqMsgId", :msg_id, "msg_id", :msgId, "msgId"], Map.get(response_data, :req_msg_id)) |
| 289 |
|
|
| 290 |
:-( |
xml_body = """ |
| 291 |
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 292 |
|
<upi:RespPay xmlns:upi="http://npci.org/upi/schema/"> |
| 293 |
:-( |
<Head ver="2.0" ts="#{Map.get(response_data, :head_ts, get_timestamp())}" orgId="#{Map.get(response_data, :org_id)}" msgId="#{generate_fixed_length_msg_id(Map.get(response_data, :msg_id))}" prodType="#{Map.get(response_data, :prod_type) || "UPI"}"/> |
| 294 |
:-( |
<Txn id="#{txn_id}" |
| 295 |
:-( |
note="#{validate_and_preserve_note(Map.get(request_source, :note) || Map.get(response_data, :note))}" |
| 296 |
:-( |
refId="#{Map.get(request_source, :ref_id) || Map.get(response_data, :ref_id) || ""}" |
| 297 |
:-( |
custRef="#{Map.get(request_source, :cust_ref) || Map.get(response_data, :cust_ref)}" |
| 298 |
:-( |
refUrl="#{Map.get(request_source, :ref_url) || Map.get(response_data, :ref_url) || ""}" |
| 299 |
:-( |
ts="#{Map.get(request_source, :txn_ts) || Map.get(response_data, :txn_ts, get_timestamp())}" |
| 300 |
:-( |
purpose="#{Map.get(request_source, :purpose) || Map.get(response_data, :purpose, "11") }" |
| 301 |
:-( |
type="#{Map.get(request_source, :txn_type) || Map.get(response_data, :txn_type)}" |
| 302 |
:-( |
subType="#{Map.get(request_source, :sub_type) || Map.get(response_data, :sub_type, "PAY") }" |
| 303 |
:-( |
initiationMode="#{Map.get(request_source, :initiation_mode) || Map.get(response_data, :initiation_mode, "01") }" |
| 304 |
:-( |
orgTxnId="#{Map.get(request_source, :org_txn_id) || Map.get(response_data, :org_txn_id) || ""}" |
| 305 |
:-( |
orgRrn="#{Map.get(request_source, :org_rrn) || Map.get(response_data, :org_rrn) || ""}" |
| 306 |
:-( |
orgTxnDate="#{Map.get(request_source, :org_txn_date) || Map.get(response_data, :org_txn_date) || ""}" |
| 307 |
:-( |
refCategory="#{Map.get(request_source, :ref_category) || Map.get(response_data, :ref_category, "00") }" |
| 308 |
:-( |
seqNum="#{Map.get(request_source, :seq_num) || Map.get(response_data, :seq_num) || generate_numeric_seq_num()}"> |
| 309 |
|
<RiskScores> |
| 310 |
:-( |
<Score provider="sp" type="TXNRISK" value="#{Map.get(response_data, :sp_risk_score) || generate_default_risk_score()}"/> |
| 311 |
:-( |
<Score provider="npci" type="TXNRISK" value="#{Map.get(response_data, :npci_risk_score) || generate_default_risk_score()}"/> |
| 312 |
|
</RiskScores> |
| 313 |
:-( |
<QR expireTs="#{qr_expire_ts}" qVer="#{qr_ver}" qrMedium="#{qr_medium}"#{if qr_ts_attribute, do: " ts=\"#{qr_ts_attribute}\"", else: ""} query="#{qr_query}"#{if qr_ver_token_attribute, do: " verToken=\"#{qr_ver_token_attribute}\"", else: ""} /> |
| 314 |
|
</Txn> |
| 315 |
:-( |
<Resp reqMsgId="#{resp_req_msg_id || ""}" result="#{Map.get(response_data, :result)}"> |
| 316 |
:-( |
<Ref type="PAYEE" seqNum="#{ref_seq}" addr="#{payee_addr}" code="#{Map.get(response_data, :payee_code)}" orgAmount="#{Map.get(response_data, :org_amount)}" respCode="#{Map.get(response_data, :resp_code) || ""}" regName="#{Map.get(response_data, :reg_name)}" IFSC="#{Map.get(response_data, :ifsc)}" acNum="#{Map.get(response_data, :ac_num)}" accType="#{Map.get(response_data, :acc_type) || ""}" approvalNum="#{approval_num_final}" settAmount="#{Map.get(response_data, :sett_amount)}" settCurrency="#{Map.get(response_data, :sett_currency) || ""}" /> |
| 317 |
|
</Resp> |
| 318 |
|
""" |
| 319 |
|
|
| 320 |
|
# Dynamically generate the Signature block using the built XML (excluding closing tag) |
| 321 |
:-( |
signature_block = generate_signature_block(xml_body) |
| 322 |
|
|
| 323 |
|
# Append Signature block and closing tag to form complete XML |
| 324 |
:-( |
xml = xml_body <> "\n" <> signature_block <> "\n</upi:RespPay>" |
| 325 |
|
|
| 326 |
|
{:ok, xml} |
| 327 |
|
end |
| 328 |
|
|
| 329 |
|
defp generate_signature_block(xml_body) do |
| 330 |
|
# Implements actual XML digital signature generation using RSA private key. |
| 331 |
|
# Uses :crypto for digest and signature, assumes PEM private key is loaded from config. |
| 332 |
|
# Returns XML Signature block as per XMLDSIG standard. |
| 333 |
|
|
| 334 |
|
# Load private key from config (PEM format) |
| 335 |
:-( |
priv_key_path = Application.get_env(:da_product_app, :xml_signing_priv_key_path, "/var/www/internaltesting/madhoolika/prverification/upi_psp_platform/private.pem") |
| 336 |
:-( |
priv_key_pem = File.read!(priv_key_path) |
| 337 |
:-( |
[entry] = :public_key.pem_decode(priv_key_pem) |
| 338 |
:-( |
priv_key = :public_key.pem_entry_decode(entry) |
| 339 |
|
|
| 340 |
|
# Improved canonicalization for NPCI compatibility |
| 341 |
:-( |
canonical_xml = |
| 342 |
|
xml_body |
| 343 |
|
|> String.replace(~r/>\s+</, "><") # Remove whitespace between tags |
| 344 |
|
|> String.replace(~r/\s+/, " ") # Normalize multiple spaces to single space |
| 345 |
|
|> String.replace(~r/\s+>/, ">") # Remove trailing spaces before closing tags |
| 346 |
|
|> String.replace(~r/<\s+/, "<") # Remove leading spaces after opening tags |
| 347 |
|
|> String.trim() |
| 348 |
|
|
| 349 |
|
# Compute SHA256 digest of canonicalized XML |
| 350 |
:-( |
digest = :crypto.hash(:sha256, canonical_xml) |> Base.encode64() |
| 351 |
|
|
| 352 |
|
# Sign the digest using RSA private key (PKCS1 v1.5) |
| 353 |
:-( |
signature = :public_key.sign(canonical_xml, :sha256, priv_key) |> Base.encode64() |
| 354 |
|
|
| 355 |
|
# Extract modulus and exponent for KeyInfo |
| 356 |
:-( |
{:RSAPrivateKey, :"two-prime", modulus, public_exponent, _, _, _, _, _, _, _} = priv_key |
| 357 |
:-( |
modulus_b64 = :binary.encode_unsigned(modulus) |> Base.encode64() |
| 358 |
:-( |
exponent_b64 = :binary.encode_unsigned(public_exponent) |> Base.encode64() |
| 359 |
|
|
| 360 |
:-( |
""" |
| 361 |
|
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"> |
| 362 |
|
<SignedInfo> |
| 363 |
|
<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> |
| 364 |
|
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> |
| 365 |
|
<Reference URI=""> |
| 366 |
|
<Transforms> |
| 367 |
|
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/> |
| 368 |
|
</Transforms> |
| 369 |
|
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> |
| 370 |
:-( |
<DigestValue>#{digest}</DigestValue> |
| 371 |
|
</Reference> |
| 372 |
|
</SignedInfo> |
| 373 |
:-( |
<SignatureValue>#{signature}</SignatureValue> |
| 374 |
|
<KeyInfo> |
| 375 |
|
<KeyValue> |
| 376 |
|
<RSAKeyValue> |
| 377 |
:-( |
<Modulus>#{modulus_b64}</Modulus> |
| 378 |
:-( |
<Exponent>#{exponent_b64}</Exponent> |
| 379 |
|
</RSAKeyValue> |
| 380 |
|
</KeyValue> |
| 381 |
|
</KeyInfo> |
| 382 |
|
</Signature> |
| 383 |
|
""" |
| 384 |
|
end |
| 385 |
|
@doc """ |
| 386 |
|
Generate International Credit Request (ReqPay) XML |
| 387 |
|
Creates the full international UPI request with merchant details, risk scores, and device info |
| 388 |
|
""" |
| 389 |
|
def generate_international_req_pay(request_data) do |
| 390 |
:-( |
meta_section = if request_data.pay_req_start || request_data.pay_req_end do |
| 391 |
:-( |
""" |
| 392 |
|
<Meta> |
| 393 |
:-( |
#{if request_data.pay_req_start, do: "<Tag name=\"PAYREQSTART\" value=\"#{request_data.pay_req_start}\"/>", else: ""} |
| 394 |
:-( |
#{if request_data.pay_req_end, do: "<Tag name=\"PAYREQEND\" value=\"#{request_data.pay_req_end}\"/>", else: ""} |
| 395 |
|
</Meta> |
| 396 |
|
""" |
| 397 |
|
else |
| 398 |
|
"" |
| 399 |
|
end |
| 400 |
|
|
| 401 |
:-( |
device_section = if has_device_info?(request_data) do |
| 402 |
:-( |
""" |
| 403 |
|
<Device> |
| 404 |
:-( |
#{if request_data.mobile, do: "<Tag name=\"MOBILE\" value=\"#{request_data.mobile}\"/>", else: ""} |
| 405 |
:-( |
#{if request_data.geocode, do: "<Tag name=\"GEOCODE\" value=\"#{request_data.geocode}\"/>", else: ""} |
| 406 |
:-( |
#{if request_data.location, do: "<Tag name=\"LOCATION\" value=\"#{request_data.location}\"/>", else: ""} |
| 407 |
:-( |
#{if request_data.ip, do: "<Tag name=\"IP\" value=\"#{request_data.ip}\"/>", else: ""} |
| 408 |
:-( |
#{if request_data.device_type, do: "<Tag name=\"TYPE\" value=\"#{request_data.device_type}\"/>", else: ""} |
| 409 |
:-( |
#{if request_data.device_id, do: "<Tag name=\"ID\" value=\"#{request_data.device_id}\"/>", else: ""} |
| 410 |
:-( |
#{if request_data.device_os, do: "<Tag name=\"OS\" value=\"#{request_data.device_os}\"/>", else: ""} |
| 411 |
:-( |
#{if request_data.device_app, do: "<Tag name=\"APP\" value=\"#{request_data.device_app}\"/>", else: ""} |
| 412 |
:-( |
#{if request_data.device_capability, do: "<Tag name=\"CAPABILITY\" value=\"#{request_data.device_capability}\"/>", else: ""} |
| 413 |
|
</Device> |
| 414 |
|
""" |
| 415 |
|
else |
| 416 |
|
"" |
| 417 |
|
end |
| 418 |
|
|
| 419 |
:-( |
xml = """ |
| 420 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 421 |
|
<upi:ReqPay xmlns:upi="http://npci.org/upi/schema/"> |
| 422 |
:-( |
<Head ver="2.0" ts="#{get_timestamp()}" orgId="#{request_data.org_id}" msgId="#{request_data.msg_id}" prodType="#{request_data.prod_type || "UPI"}"/> |
| 423 |
:-( |
#{meta_section} |
| 424 |
:-( |
<Txn id="#{request_data.txn_id}" note="#{request_data.note || ""}" custRef="#{request_data.cust_ref}" refId="#{request_data.ref_id || ""}" refUrl="#{request_data.ref_url || ""}" ts="#{get_timestamp()}" orgTxnId="#{request_data.org_txn_id}" refCategory="#{request_data.ref_category || ""}" type="#{request_data.txn_type}" purpose="11" subType="#{request_data.sub_type || ""}" initiationMode="#{request_data.initiation_mode || "QR"}" orgRrn="#{request_data.org_rrn || ""}" orgTxnDate="#{request_data.org_txn_date || get_date()}"> |
| 425 |
|
<RiskScores> |
| 426 |
:-( |
<Score provider="sp" type="TXNRISK" value="#{request_data.sp_risk_score || "0"}"/> |
| 427 |
:-( |
<Score provider="npci" type="TXNRISK" value="#{request_data.npci_risk_score || "0"}"/> |
| 428 |
|
</RiskScores> |
| 429 |
|
<Rules> |
| 430 |
:-( |
<Rule name="EXPIREAFTER" value="#{request_data.expire_after || "30"} minutes"/> |
| 431 |
:-( |
#{if request_data.min_amount, do: "<Rule name=\"MINAMOUNT\" value=\"#{request_data.min_amount}\"/>", else: ""} |
| 432 |
|
</Rules> |
| 433 |
:-( |
<QR qVer="#{request_data.qr_ver || "2.0"}" ts="#{get_timestamp()}" qrMedium="#{request_data.qr_medium || "04"}" expireTs="#{request_data.expire_ts || get_expiry_timestamp()}" query="#{request_data.qr_query || ""}" verToken="#{request_data.ver_token || generate_verification_token()}" stan="#{request_data.stan || generate_stan()}"/> |
| 434 |
|
</Txn> |
| 435 |
:-( |
<Payer addr="#{request_data.payer_addr}" name="#{request_data.payer_name}" seqNum="#{request_data.payer_seq_num || "1"}" type="#{request_data.payer_type || "PERSON"}" code="#{request_data.payer_code || "0000"}"> |
| 436 |
:-( |
#{device_section} |
| 437 |
|
<Ac addrType="ACCOUNT"> |
| 438 |
:-( |
<Detail name="ACTYPE" value="#{request_data.payer_ac_type || "SAVINGS"}"/> |
| 439 |
:-( |
<Detail name="IFSC" value="#{request_data.payer_ifsc}"/> |
| 440 |
:-( |
<Detail name="ACNUM" value="#{request_data.payer_ac_num}"/> |
| 441 |
|
</Ac> |
| 442 |
:-( |
<Amount value="#{request_data.payer_amount}" curr="#{request_data.payer_currency || "INR"}"/> |
| 443 |
:-( |
<Institution QrPayLoad="#{request_data.qr_payload || ""}" conCode="#{request_data.con_code || "IN"}" netInstId="#{request_data.net_inst_id}"/> |
| 444 |
|
</Payer> |
| 445 |
|
<Payees> |
| 446 |
:-( |
<Payee addr="#{request_data.payee_addr}" name="#{request_data.payee_name}" seqNum="#{request_data.payee_seq_num || "1"}" type="#{request_data.payee_type || "ENTITY"}" code="#{request_data.payee_code}"> |
| 447 |
|
<Merchant> |
| 448 |
:-( |
<Identifier subCode="#{request_data.sub_code || ""}" mid="#{request_data.mid}" sid="#{request_data.sid || ""}" tid="#{request_data.tid || ""}" merchantType="#{request_data.merchant_type}" merchantGenre="#{extract_merchant_genre_from_qr(request_data) || request_data.merchant_genre || ""}" onBoardingType="#{request_data.onboarding_type || ""}" regId="#{request_data.reg_id || ""}" pinCode="#{request_data.pin_code || ""}" tier="#{request_data.tier || ""}" merchantLoc="#{request_data.merchant_loc || ""}" merchantInstId="#{request_data.merchant_inst_id || ""}"/> |
| 449 |
:-( |
<Name brand="#{request_data.brand}" legal="#{request_data.legal}" franchise="#{request_data.franchise || ""}"/> |
| 450 |
:-( |
<Ownership type="#{request_data.ownership_type || "PRIVATE"}"/> |
| 451 |
:-( |
<Invoice date="#{request_data.invoice_date || get_date()}" name="#{request_data.invoice_name || request_data.payee_name}" num="#{request_data.invoice_num || generate_invoice_number()}"/> |
| 452 |
|
</Merchant> |
| 453 |
|
<Ac addrType="ACCOUNT"> |
| 454 |
:-( |
<Detail name="IFSC" value="#{request_data.payee_ifsc}"/> |
| 455 |
:-( |
<Detail name="ACTYPE" value="#{request_data.payee_ac_type || "CURRENT"}"/> |
| 456 |
:-( |
<Detail name="ACNUM" value="#{request_data.payee_ac_num}"/> |
| 457 |
|
</Ac> |
| 458 |
:-( |
<Amount value="#{request_data.payee_amount}" curr="#{request_data.payee_currency || "INR"}"> |
| 459 |
:-( |
#{if request_data.base_amount, do: "<Split name=\"baseAmount\" value=\"#{request_data.base_amount}\"/>", else: ""} |
| 460 |
:-( |
#{if request_data.base_curr, do: "<Split name=\"baseCurr\" value=\"#{request_data.base_curr}\"/>", else: ""} |
| 461 |
:-( |
#{if request_data.fx_rate, do: "<Split name=\"FX\" value=\"#{request_data.fx_rate}\"/>", else: ""} |
| 462 |
:-( |
#{if request_data.markup, do: "<Split name=\"Mkup\" value=\"#{request_data.markup}\"/>", else: ""} |
| 463 |
|
</Amount> |
| 464 |
|
</Payee> |
| 465 |
|
</Payees> |
| 466 |
|
</upi:ReqPay> |
| 467 |
|
""" |
| 468 |
|
|
| 469 |
|
{:ok, xml} |
| 470 |
|
end |
| 471 |
|
|
| 472 |
|
@doc """ |
| 473 |
|
Parse ReqChkTxn XML with full UPI specification compliance |
| 474 |
|
""" |
| 475 |
|
def parse_req_chk_txn(xml_string) do |
| 476 |
:-( |
try do |
| 477 |
:-( |
with {:ok, data} <- extract_req_chk_txn_data(xml_string), |
| 478 |
:-( |
{:ok, fields} <- validate_req_chk_txn_fields(data) do |
| 479 |
|
{:ok, fields} |
| 480 |
|
else |
| 481 |
:-( |
error -> error |
| 482 |
|
end |
| 483 |
|
rescue |
| 484 |
:-( |
e -> {:error, "XML parsing failed: #{inspect(e)}"} |
| 485 |
|
end |
| 486 |
|
end |
| 487 |
|
|
| 488 |
|
# Dedicated extractor for ReqChkTxn messages. Returns a cleaned map with |
| 489 |
|
# the most important attributes including note, refId, refUrl and refCategory. |
| 490 |
|
defp extract_req_chk_txn_data(xml_string) do |
| 491 |
:-( |
try do |
| 492 |
:-( |
if String.trim(xml_string) == "" do |
| 493 |
|
{:error, "Empty XML string"} |
| 494 |
|
else |
| 495 |
:-( |
doc = xml_string |> parse(quiet: true) |
| 496 |
|
|
| 497 |
|
# Robust extraction for Txn.subType (handle namespaced, different casing and element variants) |
| 498 |
:-( |
sub_type_attr = doc |> xpath(~x"//Txn/@subType"s) |
| 499 |
:-( |
sub_type_fallback = fallback_element(sub_type_attr, doc, "//Txn/subType/text()") |
| 500 |
|
|
| 501 |
:-( |
sub_type_val = |
| 502 |
|
cond do |
| 503 |
:-( |
sub_type_fallback && sub_type_fallback != "" -> |
| 504 |
:-( |
sub_type_fallback |
| 505 |
|
|
| 506 |
:-( |
true -> |
| 507 |
|
# Try several alternative locations/casings commonly seen in incoming XML |
| 508 |
:-( |
alt_ns_attr = doc |> xpath(~x"//ns2:Txn/@subType"s) |
| 509 |
:-( |
if alt_ns_attr && alt_ns_attr != "" do |
| 510 |
:-( |
alt_ns_attr |
| 511 |
|
else |
| 512 |
:-( |
alt_lower_attr = doc |> xpath(~x"//Txn/@subtype"s) |
| 513 |
:-( |
if alt_lower_attr && alt_lower_attr != "" do |
| 514 |
:-( |
alt_lower_attr |
| 515 |
|
else |
| 516 |
:-( |
alt_elem = doc |> xpath(~x"//Txn/subType/text()"s) |
| 517 |
:-( |
if alt_elem && alt_elem != "" do |
| 518 |
:-( |
alt_elem |
| 519 |
|
else |
| 520 |
:-( |
alt_ns_elem = doc |> xpath(~x"//ns2:Txn/subType/text()"s) |
| 521 |
:-( |
if alt_ns_elem && alt_ns_elem != "" do |
| 522 |
:-( |
alt_ns_elem |
| 523 |
|
else |
| 524 |
|
nil |
| 525 |
|
end |
| 526 |
|
end |
| 527 |
|
end |
| 528 |
|
end |
| 529 |
|
end |
| 530 |
|
|> case do |
| 531 |
:-( |
nil -> nil |
| 532 |
:-( |
v -> String.upcase(String.trim(v)) |
| 533 |
|
end |
| 534 |
|
|
| 535 |
:-( |
data = %{ |
| 536 |
|
# Head attributes |
| 537 |
|
version: doc |> xpath(~x"//Head/@ver"s), |
| 538 |
|
timestamp: doc |> xpath(~x"//Head/@ts"s), |
| 539 |
|
org_id: doc |> xpath(~x"//Head/@orgId"s), |
| 540 |
|
msg_id: doc |> xpath(~x"//Head/@msgId"s), |
| 541 |
|
|
| 542 |
|
# Transaction attributes (ReqChkTxn) |
| 543 |
|
txn_id: doc |> xpath(~x"//Txn/@id"s), |
| 544 |
|
org_txn_id: doc |> xpath(~x"//Txn/@orgTxnId"s), |
| 545 |
|
org_msg_id: doc |> xpath(~x"//Txn/@orgMsgId"s), |
| 546 |
|
# Capture original transaction date if provided |
| 547 |
|
org_txn_date: doc |> xpath(~x"//Txn/@orgTxnDate"s), |
| 548 |
|
note: doc |> xpath(~x"//Txn/@note"s), |
| 549 |
|
ref_id: doc |> xpath(~x"//Txn/@refId"s), |
| 550 |
|
ref_url: doc |> xpath(~x"//Txn/@refUrl"s), |
| 551 |
|
ref_category: doc |> xpath(~x"//Txn/@refCategory"s), |
| 552 |
|
txn_type: doc |> xpath(~x"//Txn/@type"s), |
| 553 |
|
cust_ref: doc |> xpath(~x"//Txn/@custRef"s), |
| 554 |
|
initiation_mode: doc |> xpath(~x"//Txn/@initiationMode"s), |
| 555 |
|
purpose: doc |> xpath(~x"//Txn/@purpose"s), |
| 556 |
|
seq_num: doc |> xpath(~x"//Txn/@seqNum"s), |
| 557 |
|
sub_type: sub_type_val, |
| 558 |
|
txn_ts: doc |> xpath(~x"//Txn/@ts"s), |
| 559 |
|
|
| 560 |
|
# Payee/Reference attributes (if present in ReqChkTxn) |
| 561 |
|
payee_addr: doc |> xpath(~x"//Payee/@addr"s) |> fallback_element(doc, "//Ref/@addr"), |
| 562 |
|
payee_code: doc |> xpath(~x"//Payee/@code"s) |> fallback_element(doc, "//Ref/@code"), |
| 563 |
|
reg_name: doc |> xpath(~x"//Payee/@regName"s) |> fallback_element(doc, "//Ref/@regName"), |
| 564 |
|
ifsc: doc |> xpath(~x"//Payee/Ac/Detail[@name='IFSC']/@value"s) |> fallback_element(doc, "//Ref/@IFSC"), |
| 565 |
|
ac_num: doc |> xpath(~x"//Payee/Ac/Detail[@name='ACNUM']/@value"s) |> fallback_element(doc, "//Ref/@acNum"), |
| 566 |
|
|
| 567 |
|
# Additional Reference fields that might be in Ref element |
| 568 |
|
acc_type: doc |> xpath(~x"//Ref/@accType"s), |
| 569 |
|
approval_num: doc |> xpath(~x"//Ref/@approvalNum"s), |
| 570 |
|
sett_amount: doc |> xpath(~x"//Ref/@settAmount"s), |
| 571 |
|
sett_currency: doc |> xpath(~x"//Ref/@settCurrency"s), |
| 572 |
|
org_amount: doc |> xpath(~x"//Ref/@orgAmount"s), |
| 573 |
|
resp_code: doc |> xpath(~x"//Ref/@respCode"s) |
| 574 |
|
} |
| 575 |
|
|
| 576 |
:-( |
cleaned_data = data |
| 577 |
:-( |
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) |
| 578 |
|
|> Enum.into(%{}) |
| 579 |
|
|
| 580 |
|
{:ok, cleaned_data} |
| 581 |
|
end |
| 582 |
|
rescue |
| 583 |
:-( |
e -> |
| 584 |
:-( |
IO.inspect(e, label: "ReqChkTxn XML Parsing Error") |
| 585 |
:-( |
{:error, "XML parsing failed: #{Exception.message(e)}"} |
| 586 |
|
end |
| 587 |
|
end |
| 588 |
|
|
| 589 |
|
@doc """ |
| 590 |
|
Generate RespChkTxn XML response |
| 591 |
|
""" |
| 592 |
|
def generate_resp_chk_txn(response_data) do |
| 593 |
|
# Ensure required fields for NPCI: Head.msgId (max 35) and Txn.subType |
| 594 |
|
# Build a well-formed RespChkTxn XML. Keep attributes inline and ensure spacing is correct |
| 595 |
|
|
| 596 |
|
# Guarantee a proper msgId (generate if missing) and enforce NPCI max length |
| 597 |
|
# Accept msgId from several possible key forms and normalize |
| 598 |
:-( |
raw_msg_id = |
| 599 |
|
response_data |
| 600 |
|
|> (fn rd -> |
| 601 |
:-( |
Map.get(rd, :msg_id) || Map.get(rd, :msgId) || Map.get(rd, "msgId") || Map.get(rd, "msg_id") || Map.get(rd, "msgid") || Map.get(rd, :msg) || nil |
| 602 |
|
end).() |
| 603 |
|
|
| 604 |
:-( |
head_msg_id = |
| 605 |
|
raw_msg_id |
| 606 |
|
|> generate_fixed_length_msg_id() |
| 607 |
:-( |
|> to_string() |
| 608 |
|
|> String.replace(~r/\s+/, "") |
| 609 |
|
|> String.slice(0, 35) |
| 610 |
|
|
| 611 |
|
# Normalize/derive subType with a safe default (CREDIT) to satisfy NPCI validation |
| 612 |
:-( |
sub_type = |
| 613 |
:-( |
case Map.get(response_data, :sub_type) || Map.get(response_data, "sub_type") || Map.get(response_data, :subType) do |
| 614 |
:-( |
v when is_binary(v) and v != "" -> String.upcase(String.trim(v)) |
| 615 |
:-( |
_ -> "CREDIT" |
| 616 |
|
end |
| 617 |
|
|
| 618 |
|
# Ensure initiationMode, txn id and purpose have safe defaults to satisfy NPCI |
| 619 |
:-( |
initiation_mode = |
| 620 |
:-( |
case Map.get(response_data, :initiation_mode) || Map.get(response_data, :initiationMode) || Map.get(response_data, "initiation_mode") do |
| 621 |
:-( |
v when is_binary(v) and v != "" -> v |
| 622 |
:-( |
_ -> "01" |
| 623 |
|
end |
| 624 |
|
|
| 625 |
|
# Txn id must be present and exactly 35 chars: 3-char prefix + 32 hex lowercase chars. |
| 626 |
:-( |
raw_txn_id = |
| 627 |
:-( |
Map.get(response_data, :txn_id) || Map.get(response_data, :txnId) || Map.get(response_data, "txn_id") || Map.get(response_data, "txnId") || Map.get(response_data, :id) || Map.get(response_data, "id") |
| 628 |
|
|
| 629 |
:-( |
gen_hex = fn n -> |
| 630 |
|
:crypto.strong_rand_bytes(div(n + 1, 2)) |
| 631 |
|
|> Base.encode16(case: :lower) |
| 632 |
:-( |
|> String.slice(0, n) |
| 633 |
|
end |
| 634 |
|
|
| 635 |
:-( |
prefix = |
| 636 |
|
Application.get_env(:da_product_app, :psp_org_prefix, "MER") |
| 637 |
:-( |
|> to_string() |
| 638 |
|
|> String.replace(~r/[^A-Za-z0-9]/, "") |
| 639 |
|
|> String.slice(0, 3) |
| 640 |
|
|
| 641 |
:-( |
txn_id = |
| 642 |
|
case raw_txn_id do |
| 643 |
|
v when is_binary(v) and v != "" -> |
| 644 |
|
# Normalize: remove whitespace and any non-alphanumeric chars |
| 645 |
:-( |
cleaned = v |> String.replace(~r/[^A-Za-z0-9]/, "") |> String.trim() |
| 646 |
|
|
| 647 |
:-( |
cond do |
| 648 |
|
String.length(cleaned) == 35 -> |
| 649 |
|
# If exactly 35 chars, ensure format: keep prefix as configured, lowercase hex tail |
| 650 |
:-( |
head = String.slice(cleaned, 0, 3) |
| 651 |
:-( |
tail = String.slice(cleaned, 3, 32) || "" |
| 652 |
:-( |
prefix <> String.downcase(tail) |
| 653 |
|
|
| 654 |
:-( |
String.length(cleaned) > 35 -> |
| 655 |
|
# Truncate to 35, normalize tail to lowercase |
| 656 |
:-( |
head = String.slice(cleaned, 0, 3) |
| 657 |
:-( |
tail = String.slice(cleaned, 3, 32) || "" |
| 658 |
:-( |
prefix <> String.downcase(tail) |
| 659 |
|
|
| 660 |
:-( |
true -> |
| 661 |
|
# Not enough length: try to reuse any hex characters from cleaned, pad with random hex to reach 32 |
| 662 |
|
# extract hex chars from cleaned (prefer keeping any entropy provided) |
| 663 |
:-( |
candidate_tail = |
| 664 |
|
cleaned |
| 665 |
|
|> String.slice(3..-1) # prefer characters after any prefix |
| 666 |
:-( |
|> to_string() |
| 667 |
|
|> String.replace(~r/[^A-Fa-f0-9]/, "") |
| 668 |
|
|> String.downcase() |
| 669 |
|
|
| 670 |
:-( |
needed = 32 - String.length(candidate_tail) |
| 671 |
:-( |
tail = |
| 672 |
|
if needed <= 0 do |
| 673 |
:-( |
String.slice(candidate_tail, 0, 32) |
| 674 |
|
else |
| 675 |
:-( |
candidate_tail <> gen_hex.(needed) |
| 676 |
|
end |
| 677 |
|
|
| 678 |
:-( |
(prefix <> tail) |> String.slice(0, 35) |
| 679 |
|
end |
| 680 |
|
|
| 681 |
|
_ -> |
| 682 |
|
# Generate fresh id: prefix + 32 hex lowercase characters |
| 683 |
:-( |
prefix <> gen_hex.(32) |
| 684 |
|
end |
| 685 |
|
|
| 686 |
:-( |
purpose = |
| 687 |
:-( |
case Map.get(response_data, :purpose) || Map.get(response_data, "purpose") do |
| 688 |
:-( |
v when is_binary(v) and v != "" -> v |
| 689 |
:-( |
_ -> "11" |
| 690 |
|
end |
| 691 |
|
|
| 692 |
:-( |
xml = ~s""" |
| 693 |
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 694 |
|
<ns2:RespChkTxn xmlns:ns2="http://npci.org/upi/schema/"> |
| 695 |
:-( |
<Head ver="#{Map.get(response_data, :ver, "2.0")}" ts="#{Map.get(response_data, :ts, get_timestamp())}" orgId="#{Map.get(response_data, :org_id, get_psp_org_id())}" msgId="#{head_msg_id}" prodType="#{Map.get(response_data, :prod_type, "UPI")}"/> |
| 696 |
:-( |
<Txn id="#{txn_id}" note="#{Map.get(response_data, :note, "")}" refId="#{Map.get(response_data, :ref_id, "")}" refUrl="#{Map.get(response_data, :ref_url, "https://mercurypay.ariticapp.com")}" refCategory="#{Map.get(response_data, :ref_category, "")}" ts="#{Map.get(response_data, :txn_ts, get_timestamp())}" custRef="#{Map.get(response_data, :cust_ref, "")}" type="ChkTxn" orgMsgId="#{Map.get(response_data, :org_msg_id, "")}" orgTxnId="#{Map.get(response_data, :org_txn_id, "")}" orgTxnDate="#{Map.get(response_data, :org_txn_date, "")}" initiationMode="#{initiation_mode}" purpose="#{purpose}" seqNum="#{Map.get(response_data, :seq_num, "")}" subType="#{sub_type}"/> |
| 697 |
:-( |
<Resp reqMsgId="#{Map.get(response_data, :req_msg_id, "")}" result="#{Map.get(response_data, :result, "SUCCESS")}"#{if Map.get(response_data, :err_code) && Map.get(response_data, :err_code) != "00", do: " errCode=\"#{Map.get(response_data, :err_code)}\"", else: ""}> |
| 698 |
:-( |
<Ref type="PAYEE" seqNum="#{Map.get(response_data, :seq_num, "1")}" addr="#{Map.get(response_data, :payee_addr, "")}" code="#{Map.get(response_data, :payee_code, "")}" orgAmount="#{Map.get(response_data, :org_amount, "0.00")}" respCode="#{Map.get(response_data, :resp_code, "")}" regName="#{Map.get(response_data, :reg_name, "")}" IFSC="#{Map.get(response_data, :ifsc, "")}" acNum="#{Map.get(response_data, :ac_num, "")}" accType="#{Map.get(response_data, :acc_type, "CURRENT")}" approvalNum="#{Map.get(response_data, :approval_num, "")}" settAmount="#{Map.get(response_data, :sett_amount, "0.00")}" settCurrency="#{Map.get(response_data, :sett_currency, "INR")}"/> |
| 699 |
|
</Resp> |
| 700 |
|
</ns2:RespChkTxn> |
| 701 |
|
""" |
| 702 |
|
|
| 703 |
|
# Normalize whitespace (keep indentation minimal) and ensure no accidental double-quotes issues |
| 704 |
:-( |
xml = xml |
| 705 |
|
|> String.replace(~r/\s+\n/, "\n") |
| 706 |
|
|> String.replace(~r/\n\s+/, "\n") |
| 707 |
|
|
| 708 |
|
{:ok, String.trim(xml)} |
| 709 |
|
end |
| 710 |
|
|
| 711 |
|
@doc """ |
| 712 |
|
Parse ReqHbt XML with full UPI specification compliance |
| 713 |
|
Expected format: |
| 714 |
|
<ns2:ReqHbt xmlns:ns2="http://npci.org/upi/schema/"> |
| 715 |
|
<Head ver="2.0" ts="2024-01-29T13:13:55+05:30" orgId="NPCI" msgId="PAM3eaf410eff2349638897034ef263d1e3"/> |
| 716 |
|
<Txn id="PAM3aee05cd100641a8b263d58f749abea8" note="ReqHbt" refId="083231151104" refUrl="www.test.co.in" ts="2024-01-29T13:13:55+05:30" type="Hbt" custRef="083231151104"/> |
| 717 |
|
<HbtMsg type="ALIVE" value="NA"/> |
| 718 |
|
</ns2:ReqHbt> |
| 719 |
|
""" |
| 720 |
|
@doc """ |
| 721 |
|
Parse ReqHbt XML with full UPI specification compliance. |
| 722 |
|
Handles both namespaced and non-namespaced tags. |
| 723 |
|
""" |
| 724 |
|
def parse_req_hbt(xml_string) do |
| 725 |
|
require Logger |
| 726 |
:-( |
Logger.info("Parsing ReqHbt XML, size: #{byte_size(xml_string)}") |
| 727 |
:-( |
Logger.debug("XML content: #{inspect(xml_string)}") |
| 728 |
|
|
| 729 |
:-( |
try do |
| 730 |
:-( |
doc = Floki.parse_document!(xml_string) |
| 731 |
:-( |
Logger.debug("Parsed Floki document: #{inspect(doc)}") |
| 732 |
|
|
| 733 |
|
# Extract Head attributes (try all case and namespace variants) |
| 734 |
:-( |
version = |
| 735 |
:-( |
Floki.attribute(doc, "Head", "ver") |> List.first() || |
| 736 |
:-( |
Floki.attribute(doc, "ns2:Head", "ver") |> List.first() || |
| 737 |
:-( |
Floki.attribute(doc, "head", "ver") |> List.first() || |
| 738 |
:-( |
Floki.attribute(doc, "ns2:head", "ver") |> List.first() || |
| 739 |
|
"2.0" |
| 740 |
|
|
| 741 |
:-( |
timestamp = |
| 742 |
:-( |
Floki.attribute(doc, "Head", "ts") |> List.first() || |
| 743 |
:-( |
Floki.attribute(doc, "ns2:Head", "ts") |> List.first() || |
| 744 |
:-( |
Floki.attribute(doc, "head", "ts") |> List.first() || |
| 745 |
:-( |
Floki.attribute(doc, "ns2:head", "ts") |> List.first() |
| 746 |
|
|
| 747 |
:-( |
org_id = |
| 748 |
:-( |
Floki.attribute(doc, "Head", "orgId") |> List.first() || |
| 749 |
:-( |
Floki.attribute(doc, "ns2:Head", "orgId") |> List.first() || |
| 750 |
:-( |
Floki.attribute(doc, "head", "orgid") |> List.first() || |
| 751 |
:-( |
Floki.attribute(doc, "ns2:head", "orgid") |> List.first() |
| 752 |
|
|
| 753 |
:-( |
msg_id = |
| 754 |
:-( |
Floki.attribute(doc, "Head", "msgId") |> List.first() || |
| 755 |
:-( |
Floki.attribute(doc, "ns2:Head", "msgId") |> List.first() || |
| 756 |
:-( |
Floki.attribute(doc, "head", "msgid") |> List.first() || |
| 757 |
:-( |
Floki.attribute(doc, "ns2:head", "msgid") |> List.first() |
| 758 |
|
|
| 759 |
|
# Extract Txn attributes (try all case and namespace variants) |
| 760 |
:-( |
txn_id = |
| 761 |
:-( |
Floki.attribute(doc, "Txn", "id") |> List.first() || |
| 762 |
:-( |
Floki.attribute(doc, "ns2:Txn", "id") |> List.first() || |
| 763 |
:-( |
Floki.attribute(doc, "txn", "id") |> List.first() || |
| 764 |
:-( |
Floki.attribute(doc, "ns2:txn", "id") |> List.first() |
| 765 |
|
|
| 766 |
:-( |
note = |
| 767 |
:-( |
Floki.attribute(doc, "Txn", "note") |> List.first() || |
| 768 |
:-( |
Floki.attribute(doc, "ns2:Txn", "note") |> List.first() || |
| 769 |
:-( |
Floki.attribute(doc, "txn", "note") |> List.first() || |
| 770 |
:-( |
Floki.attribute(doc, "ns2:txn", "note") |> List.first() |
| 771 |
|
|
| 772 |
:-( |
ref_id = |
| 773 |
:-( |
Floki.attribute(doc, "Txn", "refId") |> List.first() || |
| 774 |
:-( |
Floki.attribute(doc, "ns2:Txn", "refId") |> List.first() || |
| 775 |
:-( |
Floki.attribute(doc, "txn", "refId") |> List.first() || |
| 776 |
:-( |
Floki.attribute(doc, "ns2:txn", "refId") |> List.first() |
| 777 |
|
|
| 778 |
:-( |
ref_url = |
| 779 |
:-( |
Floki.attribute(doc, "Txn", "refUrl") |> List.first() || |
| 780 |
:-( |
Floki.attribute(doc, "ns2:Txn", "refUrl") |> List.first() || |
| 781 |
:-( |
Floki.attribute(doc, "txn", "refUrl") |> List.first() || |
| 782 |
:-( |
Floki.attribute(doc, "ns2:txn", "refUrl") |> List.first() |
| 783 |
|
|
| 784 |
:-( |
txn_timestamp = |
| 785 |
:-( |
Floki.attribute(doc, "Txn", "ts") |> List.first() || |
| 786 |
:-( |
Floki.attribute(doc, "ns2:Txn", "ts") |> List.first() || |
| 787 |
:-( |
Floki.attribute(doc, "txn", "ts") |> List.first() || |
| 788 |
:-( |
Floki.attribute(doc, "ns2:txn", "ts") |> List.first() |
| 789 |
|
|
| 790 |
:-( |
txn_type = |
| 791 |
:-( |
Floki.attribute(doc, "Txn", "type") |> List.first() || |
| 792 |
:-( |
Floki.attribute(doc, "ns2:Txn", "type") |> List.first() || |
| 793 |
:-( |
Floki.attribute(doc, "txn", "type") |> List.first() || |
| 794 |
:-( |
Floki.attribute(doc, "ns2:txn", "type") |> List.first() |
| 795 |
|
|
| 796 |
:-( |
cust_ref = |
| 797 |
:-( |
Floki.attribute(doc, "Txn", "custRef") |> List.first() || |
| 798 |
:-( |
Floki.attribute(doc, "ns2:Txn", "custRef") |> List.first() || |
| 799 |
:-( |
Floki.attribute(doc, "txn", "custRef") |> List.first() || |
| 800 |
:-( |
Floki.attribute(doc, "ns2:txn", "custRef") |> List.first() |
| 801 |
|
|
| 802 |
|
# Extract HbtMsg attributes |
| 803 |
:-( |
hbt_type = |
| 804 |
:-( |
Floki.attribute(doc, "HbtMsg", "type") |> List.first() || |
| 805 |
:-( |
Floki.attribute(doc, "ns2:HbtMsg", "type") |> List.first() || |
| 806 |
:-( |
Floki.attribute(doc, "hbtmsg", "type") |> List.first() || |
| 807 |
:-( |
Floki.attribute(doc, "ns2:hbtmsg", "type") |> List.first() |
| 808 |
|
|
| 809 |
:-( |
hbt_value = |
| 810 |
:-( |
Floki.attribute(doc, "HbtMsg", "value") |> List.first() || |
| 811 |
:-( |
Floki.attribute(doc, "ns2:HbtMsg", "value") |> List.first() || |
| 812 |
:-( |
Floki.attribute(doc, "hbtmsg", "value") |> List.first() || |
| 813 |
:-( |
Floki.attribute(doc, "ns2:hbtmsg", "value") |> List.first() |
| 814 |
|
|
| 815 |
:-( |
parsed_data = %{ |
| 816 |
|
version: version, |
| 817 |
|
timestamp: timestamp, |
| 818 |
|
org_id: org_id, |
| 819 |
|
msg_id: msg_id, |
| 820 |
|
txn_id: txn_id, |
| 821 |
|
note: note, |
| 822 |
|
ref_id: ref_id, |
| 823 |
|
ref_url: ref_url, |
| 824 |
|
txn_timestamp: txn_timestamp, |
| 825 |
|
txn_type: txn_type, |
| 826 |
|
cust_ref: cust_ref, |
| 827 |
|
hbt_type: hbt_type, |
| 828 |
|
hbt_value: hbt_value |
| 829 |
|
} |
| 830 |
|
|
| 831 |
:-( |
Logger.info("Parsed heartbeat data: #{inspect(parsed_data)}") |
| 832 |
|
|
| 833 |
|
# Validate required fields |
| 834 |
:-( |
if parsed_data.msg_id && parsed_data.org_id do |
| 835 |
|
{:ok, parsed_data} |
| 836 |
|
else |
| 837 |
:-( |
Logger.error("Missing required fields - msgId: #{inspect(parsed_data.msg_id)}, orgId: #{inspect(parsed_data.org_id)}") |
| 838 |
|
{:error, "Missing required fields: msgId or orgId"} |
| 839 |
|
end |
| 840 |
|
rescue |
| 841 |
:-( |
e -> |
| 842 |
:-( |
Logger.error("XML parsing exception: #{inspect(e)}") |
| 843 |
|
{:error, "XML parsing failed: #{inspect(e)}"} |
| 844 |
|
end |
| 845 |
|
end |
| 846 |
|
|
| 847 |
|
@doc """ |
| 848 |
|
Generate Ack XML response for ReqHbt (Step 2) |
| 849 |
|
Expected format: |
| 850 |
|
<ns2:Ack api="ReqHbt" reqMsgId="PAM3eaf410eff2349638897034ef263d1e3" ts="2024-01-29T07:43:56+00:00" xmlns:ns2="http://npci.org/upi/schema/"/> |
| 851 |
|
""" |
| 852 |
|
def generate_resp_hbt(response_data) do |
| 853 |
:-( |
timestamp = DateTime.utc_now() |> DateTime.to_iso8601() |
| 854 |
|
|
| 855 |
:-( |
xml = """ |
| 856 |
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 857 |
:-( |
<ns2:Ack api="ReqHbt" reqMsgId="#{response_data.req_msg_id}" ts="#{timestamp}" xmlns:ns2="http://npci.org/upi/schema/"/> |
| 858 |
|
""" |
| 859 |
|
|
| 860 |
|
{:ok, String.trim(xml)} |
| 861 |
|
end |
| 862 |
|
|
| 863 |
|
@doc """ |
| 864 |
|
Generate Ack XML response for ReqPay (Step 2) |
| 865 |
|
Expected format: |
| 866 |
|
<ns2:Ack api="ReqPay" reqMsgId="MSW253a943ea5274753969fe264bef04919" ts="2024-12-20T18:30:17+05:30" xmlns:ns2="http://npci.org/upi/schema/"/> |
| 867 |
|
""" |
| 868 |
|
def generate_ack_reqpay_response(req_msg_id) do |
| 869 |
|
# Use Asia/Kolkata timezone and NPCI format |
| 870 |
:-( |
{:ok, dt} = DateTime.now("Asia/Kolkata") |
| 871 |
|
# Format: YYYY-MM-DDTHH:MM:SS+05:30 |
| 872 |
:-( |
timestamp = |
| 873 |
|
dt |
| 874 |
|
|> DateTime.truncate(:second) |
| 875 |
|
|> DateTime.to_iso8601() |
| 876 |
|
|
| 877 |
:-( |
xml = """ |
| 878 |
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 879 |
:-( |
<ns2:Ack api="ReqPay" reqMsgId="#{req_msg_id}" ts="#{timestamp}" |
| 880 |
|
xmlns:ns2="http://npci.org/upi/schema/"/> |
| 881 |
|
""" |
| 882 |
|
|
| 883 |
:-( |
String.trim(xml) |
| 884 |
|
end |
| 885 |
|
|
| 886 |
|
@doc """ |
| 887 |
|
Generate RespHbt XML request to send to NPCI (Step 3) |
| 888 |
|
Expected format: |
| 889 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 890 |
|
<ns2:RespHbt xmlns:ns2="http://npci.org/upi/schema/"> |
| 891 |
|
<Head msgId="PAM925048da220704ed0b99ea613457f247" orgId="PAM103" ts="2024-01-29T07:43:56+00:00" ver="2.0"/> |
| 892 |
|
<Resp reqMsgId="PAM3eaf410eff2349638897034ef263d1e3" result="SUCCESS"/> |
| 893 |
|
<Txn custRef="083231151104" id="PAM3aee05cd100641a8b263d58f749abea8" note="ReqHbt" refId="083231151104" refUrl="www.test.co.in" ts="2024-01-29T07:43:56+00:00" type="Hbt"/> |
| 894 |
|
</ns2:RespHbt> |
| 895 |
|
""" |
| 896 |
|
def generate_resp_hbt_request(request_data) do |
| 897 |
:-( |
timestamp = DateTime.utc_now() |> DateTime.to_iso8601() |
| 898 |
|
|
| 899 |
:-( |
xml = """ |
| 900 |
|
<?xml version="1.0" encoding="UTF-8"?> |
| 901 |
|
<ns2:RespHbt xmlns:ns2="http://npci.org/upi/schema/"> |
| 902 |
:-( |
<Head msgId="#{request_data.msg_id}" orgId="#{request_data.org_id}" ts="#{timestamp}" ver="2.0"/> |
| 903 |
:-( |
<Resp reqMsgId="#{request_data.req_msg_id}" result="SUCCESS"/> |
| 904 |
:-( |
<Txn custRef="#{request_data.cust_ref || ""}" id="#{request_data.txn_id}" note="ReqHbt" refId="#{request_data.ref_id || ""}" refUrl="#{request_data.ref_url || ""}" ts="#{timestamp}" type="Hbt"/> |
| 905 |
|
</ns2:RespHbt> |
| 906 |
|
""" |
| 907 |
|
|
| 908 |
|
{:ok, String.trim(xml)} |
| 909 |
|
end |
| 910 |
|
|
| 911 |
|
@doc """ |
| 912 |
|
Parse Ack response from NPCI (Step 4) |
| 913 |
|
Expected format: |
| 914 |
|
<ns2:Ack api="RespHbt" reqMsgId="PAM925048da220704ed0b99ea613457f247" ts="2024-01-29T13:14:43+05:30" xmlns:ns2="http://npci.org/upi/schema/"/> |
| 915 |
|
""" |
| 916 |
|
def parse_ack_response(xml_string) do |
| 917 |
|
require Logger |
| 918 |
:-( |
Logger.info("Parsing Ack response XML, size: #{byte_size(xml_string)}") |
| 919 |
:-( |
Logger.debug("Ack XML content: #{inspect(xml_string)}") |
| 920 |
|
|
| 921 |
:-( |
try do |
| 922 |
:-( |
doc = Floki.parse_document!(xml_string) |
| 923 |
:-( |
Logger.debug("Parsed Ack Floki document: #{inspect(doc)}") |
| 924 |
|
|
| 925 |
:-( |
parsed_data = %{ |
| 926 |
:-( |
api: (doc |> Floki.attribute("ack", "api") |> List.first()) || |
| 927 |
:-( |
(doc |> Floki.attribute("ns2:ack", "api") |> List.first()), |
| 928 |
:-( |
req_msg_id: (doc |> Floki.attribute("ack", "reqmsgid") |> List.first()) || |
| 929 |
:-( |
(doc |> Floki.attribute("ns2:ack", "reqmsgid") |> List.first()), |
| 930 |
:-( |
timestamp: (doc |> Floki.attribute("ack", "ts") |> List.first()) || |
| 931 |
:-( |
(doc |> Floki.attribute("ns2:ack", "ts") |> List.first()) |
| 932 |
|
} |
| 933 |
|
|
| 934 |
:-( |
Logger.info("Parsed Ack data: #{inspect(parsed_data)}") |
| 935 |
|
|
| 936 |
:-( |
if parsed_data.api && parsed_data.req_msg_id do |
| 937 |
|
{:ok, parsed_data} |
| 938 |
|
else |
| 939 |
|
{:error, "Missing required Ack fields: api or reqMsgId"} |
| 940 |
|
end |
| 941 |
|
rescue |
| 942 |
:-( |
e -> |
| 943 |
:-( |
Logger.error("Ack XML parsing exception: #{inspect(e)}") |
| 944 |
|
{:error, "Ack XML parsing failed: #{inspect(e)}"} |
| 945 |
|
end |
| 946 |
|
end |
| 947 |
|
|
| 948 |
|
# Private helper functions |
| 949 |
|
|
| 950 |
|
defp extract_req_val_qr_data(xml_string) do |
| 951 |
:-( |
try do |
| 952 |
|
# First, validate that we have valid XML |
| 953 |
:-( |
if String.trim(xml_string) == "" do |
| 954 |
|
{:error, "Empty XML string"} |
| 955 |
|
else |
| 956 |
|
# Clean up XML - escape any unescaped ampersands that aren't part of entities |
| 957 |
:-( |
cleaned_xml = xml_string |
| 958 |
|
|> String.replace(~r/&(?![a-zA-Z0-9#]+;)/, "&") |
| 959 |
|
|
| 960 |
|
# Parse XML using SweetXML |
| 961 |
:-( |
doc = cleaned_xml |> parse(quiet: true) |
| 962 |
|
|
| 963 |
:-( |
data = %{ |
| 964 |
|
# Head attributes or elements (try both) |
| 965 |
|
version: doc |> xpath(~x"//Head/@ver"s) |> fallback_element(doc, "//Head/version/text()"), |
| 966 |
|
timestamp: doc |> xpath(~x"//Head/@ts"s) |> fallback_element(doc, "//Head/ts/text()"), |
| 967 |
|
org_id: doc |> xpath(~x"//Head/@orgId"s) |> fallback_element(doc, "//Head/orgId/text()"), |
| 968 |
|
msg_id: doc |> xpath(~x"//Head/@msgId"s) |> fallback_element(doc, "//Head/msgId/text()"), |
| 969 |
|
|
| 970 |
|
# Transaction attributes or elements |
| 971 |
|
txn_id: doc |> xpath(~x"//Txn/@id"s) |> fallback_element(doc, "//Txn/id/text()"), |
| 972 |
|
note: doc |> xpath(~x"//Txn/@note"s) |> fallback_element(doc, "//Txn/note/text()"), |
| 973 |
|
ref_id: doc |> xpath(~x"//Txn/@refId"s) |> fallback_element(doc, "//Txn/refId/text()"), |
| 974 |
|
ref_url: doc |> xpath(~x"//Txn/@refUrl"s) |> fallback_element(doc, "//Txn/refUrl/text()"), |
| 975 |
|
txn_type: doc |> xpath(~x"//Txn/@type"s) |> fallback_element(doc, "//Txn/type/text()"), |
| 976 |
|
initiation_mode: doc |> xpath(~x"//Txn/@initiationMode"s) |> fallback_element(doc, "//Txn/initiationMode/text()"), |
| 977 |
|
purpose: doc |> xpath(~x"//Txn/@purpose"s) |> fallback_element(doc, "//Txn/purpose/text()"), |
| 978 |
|
cust_ref: doc |> xpath(~x"//Txn/@custRef"s) |> fallback_element(doc, "//Txn/custRef/text()"), |
| 979 |
|
|
| 980 |
|
# QR Payload (most important) |
| 981 |
|
qr_payload: doc |> xpath(~x"//Institution/@QrPayLoad"s) |> fallback_element(doc, "//QrPayload/text()"), |
| 982 |
|
|
| 983 |
|
# Institution attributes (may be missing for simple validation) |
| 984 |
|
net_inst_id: doc |> xpath(~x"//Institution/@netInstId"s) |> fallback_element(doc, "//Institution/netInstId/text()"), |
| 985 |
|
con_code: doc |> xpath(~x"//Institution/@conCode"s) |> fallback_element(doc, "//Institution/conCode/text()"), |
| 986 |
|
base_curr: doc |> xpath(~x"//Institution/@baseCurr"s) |> fallback_element(doc, "//Institution/baseCurr/text()"), |
| 987 |
|
|
| 988 |
|
# Payer attributes (may be optional for QR validation) |
| 989 |
|
payer_addr: doc |> xpath(~x"//Payer/@addr"s) |> fallback_element(doc, "//Payer/addr/text()"), |
| 990 |
|
payer_name: doc |> xpath(~x"//Payer/@name"s) |> fallback_element(doc, "//Payer/name/text()"), |
| 991 |
|
seq_num: doc |> xpath(~x"//Payer/@seqNum"s) |> fallback_element(doc, "//Payer/seqNum/text()"), |
| 992 |
|
payer_type: doc |> xpath(~x"//Payer/@type"s) |> fallback_element(doc, "//Payer/type/text()"), |
| 993 |
|
payer_code: doc |> xpath(~x"//Payer/@code"s) |> fallback_element(doc, "//Payer/code/text()") |
| 994 |
|
} |
| 995 |
|
|
| 996 |
|
# Filter out nil/empty values and convert to proper data structure |
| 997 |
:-( |
cleaned_data = data |
| 998 |
:-( |
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) |
| 999 |
|
|> Enum.into(%{}) |
| 1000 |
|
|
| 1001 |
|
{:ok, cleaned_data} |
| 1002 |
|
end |
| 1003 |
|
rescue |
| 1004 |
:-( |
e -> |
| 1005 |
|
# Log the actual error for debugging |
| 1006 |
:-( |
IO.inspect(e, label: "XML Parsing Error") |
| 1007 |
:-( |
{:error, "XML parsing failed: #{Exception.message(e)}"} |
| 1008 |
|
end |
| 1009 |
|
end |
| 1010 |
|
|
| 1011 |
|
# Helper function to fallback to element text if attribute is empty |
| 1012 |
|
defp fallback_element(attr_value, doc, element_path) when attr_value == "" or is_nil(attr_value) do |
| 1013 |
:-( |
doc |> xpath(~x"#{element_path}"s) |
| 1014 |
|
end |
| 1015 |
:-( |
defp fallback_element(attr_value, _doc, _element_path), do: attr_value |
| 1016 |
|
|
| 1017 |
|
defp extract_xml_data(xml_string) do |
| 1018 |
|
try do |
| 1019 |
|
# First, validate that we have valid XML |
| 1020 |
|
if String.trim(xml_string) == "" do |
| 1021 |
|
{:error, "Empty XML string"} |
| 1022 |
|
else |
| 1023 |
|
# Parse XML for non-namespaced UPI requests (ReqPay, ReqChkTxn, etc.) |
| 1024 |
|
doc = xml_string |> parse(quiet: true) |
| 1025 |
|
|
| 1026 |
|
data = %{ |
| 1027 |
|
# Head attributes |
| 1028 |
|
version: doc |> xpath(~x"//Head/@ver"s), |
| 1029 |
|
timestamp: doc |> xpath(~x"//Head/@ts"s), |
| 1030 |
|
org_id: doc |> xpath(~x"//Head/@orgId"s), |
| 1031 |
|
msg_id: doc |> xpath(~x"//Head/@msgId"s), |
| 1032 |
|
|
| 1033 |
|
# Transaction data |
| 1034 |
|
txn_id: doc |> xpath(~x"//Txn/@id"s), |
| 1035 |
|
org_txn_id: doc |> xpath(~x"//Txn/@orgTxnId"s), |
| 1036 |
|
note: doc |> xpath(~x"//Txn/@note"s), |
| 1037 |
|
ref_id: doc |> xpath(~x"//Txn/@refId"s), |
| 1038 |
|
txn_type: doc |> xpath(~x"//Txn/@type"s), |
| 1039 |
|
cust_ref: doc |> xpath(~x"//Txn/@custRef"s), |
| 1040 |
|
|
| 1041 |
|
# Amount data |
| 1042 |
|
currency: doc |> xpath(~x"//Amount/@curr"s), |
| 1043 |
|
amount: doc |> xpath(~x"//Amount/@value"s), |
| 1044 |
|
|
| 1045 |
|
# Payer/Payee data |
| 1046 |
|
payer_addr: doc |> xpath(~x"//Payer/@addr"s), |
| 1047 |
|
payer_name: doc |> xpath(~x"//Payer/@name"s), |
| 1048 |
|
payee_addr: doc |> xpath(~x"//Payee/@addr"s), |
| 1049 |
|
payee_name: doc |> xpath(~x"//Payee/@name"s), |
| 1050 |
|
payee_type: doc |> xpath(~x"//Payee/@type"s), |
| 1051 |
|
payee_code: doc |> xpath(~x"//Payee/@code"s), |
| 1052 |
|
|
| 1053 |
|
# QR and other data |
| 1054 |
|
qr_string: doc |> xpath(~x"//QrData/@qrString"s), |
| 1055 |
|
expire_after: doc |> xpath(~x"//ExpireAfter/text()"s), |
| 1056 |
|
rules: doc |> xpath(~x"//Rules/text()"s) |
| 1057 |
|
} |
| 1058 |
|
|
| 1059 |
|
# Filter out nil/empty values |
| 1060 |
|
cleaned_data = data |
| 1061 |
|
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) |
| 1062 |
|
|> Enum.into(%{}) |
| 1063 |
|
|
| 1064 |
|
{:ok, cleaned_data} |
| 1065 |
|
end |
| 1066 |
|
rescue |
| 1067 |
|
e -> |
| 1068 |
|
IO.inspect(e, label: "XML Parsing Error") |
| 1069 |
|
{:error, "XML parsing failed: #{Exception.message(e)}"} |
| 1070 |
|
end |
| 1071 |
|
end |
| 1072 |
|
|
| 1073 |
|
@doc """ |
| 1074 |
|
Extract international ReqPay XML data with enhanced structure |
| 1075 |
|
Handles merchant details, risk scores, device info, and FX splits |
| 1076 |
|
""" |
| 1077 |
|
defp extract_international_req_pay_data(xml_string) do |
| 1078 |
:-( |
try do |
| 1079 |
:-( |
if String.trim(xml_string) == "" do |
| 1080 |
|
{:error, "Empty XML string"} |
| 1081 |
|
else |
| 1082 |
|
# Clean up XML and parse with namespace support |
| 1083 |
|
require Logger |
| 1084 |
:-( |
Logger.debug("Original XML length: #{String.length(xml_string)}") |
| 1085 |
|
|
| 1086 |
:-( |
cleaned_xml = xml_string |
| 1087 |
|
|> String.replace(~r/&(?![a-zA-Z0-9#]+;)/, "&") |
| 1088 |
|
# Fix specific missing spaces between attributes that are common in malformed XML |
| 1089 |
|
|> String.replace(~r/QrPayLoad="([^"]*)"(\s*)conCode=/, ~S|QrPayLoad="\1" conCode=|) |
| 1090 |
|
|> String.replace(~r/orgTxnId="([^"]*)"([a-zA-Z])/, ~S|orgTxnId="\1" \2|) |
| 1091 |
|
# More general fix for missing whitespace between any attributes |
| 1092 |
|
|> String.replace(~r/"([a-zA-Z][a-zA-Z0-9]*=)/, ~S|" \1|) |
| 1093 |
|
|
| 1094 |
:-( |
Logger.debug("Cleaned XML length: #{String.length(cleaned_xml)}") |
| 1095 |
:-( |
Logger.debug("Cleaned XML sample: #{String.slice(cleaned_xml, 0, 500)}...") |
| 1096 |
|
|
| 1097 |
:-( |
doc = cleaned_xml |> parse(quiet: true) |
| 1098 |
|
|
| 1099 |
:-( |
data = %{ |
| 1100 |
|
# Head attributes |
| 1101 |
|
version: doc |> xpath(~x"//Head/@ver"s), |
| 1102 |
|
timestamp: doc |> xpath(~x"//Head/@ts"s), |
| 1103 |
|
org_id: doc |> xpath(~x"//Head/@orgId"s), |
| 1104 |
|
msg_id: doc |> xpath(~x"//Head/@msgId"s), |
| 1105 |
|
prod_type: doc |> xpath(~x"//Head/@prodType"s), |
| 1106 |
|
|
| 1107 |
|
# Meta tags |
| 1108 |
|
pay_req_start: doc |> xpath(~x"//Meta/Tag[@name='PAYREQSTART']/@value"s), |
| 1109 |
|
pay_req_end: doc |> xpath(~x"//Meta/Tag[@name='PAYREQEND']/@value"s), |
| 1110 |
|
|
| 1111 |
|
# Transaction attributes |
| 1112 |
|
txn_id: doc |> xpath(~x"//Txn/@id"s), |
| 1113 |
|
note: doc |> xpath(~x"//Txn/@note"s), |
| 1114 |
|
cust_ref: doc |> xpath(~x"//Txn/@custRef"s), |
| 1115 |
|
ref_id: doc |> xpath(~x"//Txn/@refId"s), |
| 1116 |
|
ref_url: doc |> xpath(~x"//Txn/@refUrl"s), |
| 1117 |
|
org_txn_id: doc |> xpath(~x"//Txn/@orgTxnId"s), |
| 1118 |
|
ref_category: doc |> xpath(~x"//Txn/@refCategory"s), |
| 1119 |
|
txn_type: doc |> xpath(~x"//Txn/@type"s), |
| 1120 |
|
purpose: doc |> xpath(~x"//Txn/@purpose"s), |
| 1121 |
|
sub_type: doc |> xpath(~x"//Txn/@subType"s), |
| 1122 |
|
initiation_mode: doc |> xpath(~x"//Txn/@initiationMode"s), |
| 1123 |
|
txn_ts: doc |> xpath(~x"//Txn/@ts"s), # Transaction timestamp - CRITICAL for QR ts matching |
| 1124 |
|
org_rrn: doc |> xpath(~x"//Txn/@orgRrn"s), |
| 1125 |
|
org_txn_date: doc |> xpath(~x"//Txn/@orgTxnDate"s), |
| 1126 |
|
|
| 1127 |
|
# Risk Scores |
| 1128 |
|
sp_risk_score: doc |> xpath(~x"//RiskScores/Score[@provider='sp']/@value"s), |
| 1129 |
|
npci_risk_score: doc |> xpath(~x"//RiskScores/Score[@provider='npci']/@value"s), |
| 1130 |
|
|
| 1131 |
|
# Rules |
| 1132 |
|
expire_after: doc |> xpath(~x"//Rules/Rule[@name='EXPIREAFTER']/@value"s), |
| 1133 |
|
min_amount: doc |> xpath(~x"//Rules/Rule[@name='MINAMOUNT']/@value"s), |
| 1134 |
|
|
| 1135 |
|
# QR attributes |
| 1136 |
|
qr_ver: doc |> xpath(~x"//QR/@qVer"s), |
| 1137 |
|
qr_ts: doc |> xpath(~x"//QR/@ts"s), # QR element timestamp |
| 1138 |
|
qr_medium: doc |> xpath(~x"//QR/@qrMedium"s), |
| 1139 |
|
expire_ts: doc |> xpath(~x"//QR/@expireTs"s), |
| 1140 |
|
qr_query: doc |> xpath(~x"//QR/@query"s), |
| 1141 |
|
ver_token: doc |> xpath(~x"//QR/@verToken"s), |
| 1142 |
|
stan: doc |> xpath(~x"//QR/@stan"s), |
| 1143 |
|
|
| 1144 |
|
# Payer details |
| 1145 |
|
payer_addr: doc |> xpath(~x"//Payer/@addr"s), |
| 1146 |
|
payer_name: doc |> xpath(~x"//Payer/@name"s), |
| 1147 |
|
payer_seq_num: doc |> xpath(~x"//Payer/@seqNum"s), |
| 1148 |
|
payer_type: doc |> xpath(~x"//Payer/@type"s), |
| 1149 |
|
payer_code: doc |> xpath(~x"//Payer/@code"s), |
| 1150 |
|
|
| 1151 |
|
# Payer Device tags |
| 1152 |
|
mobile: doc |> xpath(~x"//Payer/Device/Tag[@name='MOBILE']/@value"s), |
| 1153 |
|
geocode: doc |> xpath(~x"//Payer/Device/Tag[@name='GEOCODE']/@value"s), |
| 1154 |
|
location: doc |> xpath(~x"//Payer/Device/Tag[@name='LOCATION']/@value"s), |
| 1155 |
|
ip: doc |> xpath(~x"//Payer/Device/Tag[@name='IP']/@value"s), |
| 1156 |
|
device_type: doc |> xpath(~x"//Payer/Device/Tag[@name='TYPE']/@value"s), |
| 1157 |
|
device_id: doc |> xpath(~x"//Payer/Device/Tag[@name='ID']/@value"s), |
| 1158 |
|
device_os: doc |> xpath(~x"//Payer/Device/Tag[@name='OS']/@value"s), |
| 1159 |
|
device_app: doc |> xpath(~x"//Payer/Device/Tag[@name='APP']/@value"s), |
| 1160 |
|
device_capability: doc |> xpath(~x"//Payer/Device/Tag[@name='CAPABILITY']/@value"s), |
| 1161 |
|
|
| 1162 |
|
# Payer Account details |
| 1163 |
|
payer_ac_type: doc |> xpath(~x"//Payer/Ac/Detail[@name='ACTYPE']/@value"s), |
| 1164 |
|
payer_ifsc: doc |> xpath(~x"//Payer/Ac/Detail[@name='IFSC']/@value"s), |
| 1165 |
|
payer_ac_num: doc |> xpath(~x"//Payer/Ac/Detail[@name='ACNUM']/@value"s), |
| 1166 |
|
|
| 1167 |
|
# Payer Amount and Institution |
| 1168 |
|
payer_amount: doc |> xpath(~x"//Payer/Amount/@value"s), |
| 1169 |
|
# In extract_international_req_pay_data/1, after data = %{ ... } |
| 1170 |
|
payee_currency: ( |
| 1171 |
|
doc |> xpath(~x"//Payee/Amount/@curr"s) |
| 1172 |
:-( |
|| doc |> xpath(~x"//Payer/Amount/@curr"s) |
| 1173 |
:-( |
|| doc |> xpath(~x"//ReqPay/Amount/@curr"s) |
| 1174 |
:-( |
|| "INR" |
| 1175 |
|
), |
| 1176 |
|
qr_payload: doc |> xpath(~x"//Payer/Institution/@QrPayLoad"s), |
| 1177 |
|
con_code: doc |> xpath(~x"//Payer/Institution/@conCode"s), |
| 1178 |
|
net_inst_id: doc |> xpath(~x"//Payer/Institution/@netInstId"s), |
| 1179 |
|
|
| 1180 |
|
# Payee details |
| 1181 |
|
payee_addr: doc |> xpath(~x"//Payee/@addr"s), |
| 1182 |
|
payee_name: doc |> xpath(~x"//Payee/@name"s), |
| 1183 |
|
payee_seq_num: doc |> xpath(~x"//Payee/@seqNum"s), |
| 1184 |
|
payee_type: doc |> xpath(~x"//Payee/@type"s), |
| 1185 |
|
payee_code: doc |> xpath(~x"//Payee/@code"s), |
| 1186 |
|
|
| 1187 |
|
# Merchant Identifier |
| 1188 |
|
sub_code: doc |> xpath(~x"//Merchant/Identifier/@subCode"s), |
| 1189 |
|
mid: doc |> xpath(~x"//Merchant/Identifier/@mid"s), |
| 1190 |
|
sid: doc |> xpath(~x"//Merchant/Identifier/@sid"s), |
| 1191 |
|
tid: doc |> xpath(~x"//Merchant/Identifier/@tid"s), |
| 1192 |
|
merchant_type: doc |> xpath(~x"//Merchant/Identifier/@merchantType"s), |
| 1193 |
|
merchant_genre: doc |> xpath(~x"//Merchant/Identifier/@merchantGenre"s), |
| 1194 |
|
onboarding_type: doc |> xpath(~x"//Merchant/Identifier/@onBoardingType"s), |
| 1195 |
|
reg_id: doc |> xpath(~x"//Merchant/Identifier/@regId"s), |
| 1196 |
|
pin_code: doc |> xpath(~x"//Merchant/Identifier/@pinCode"s), |
| 1197 |
|
tier: doc |> xpath(~x"//Merchant/Identifier/@tier"s), |
| 1198 |
|
merchant_loc: doc |> xpath(~x"//Merchant/Identifier/@merchantLoc"s), |
| 1199 |
|
merchant_inst_id: doc |> xpath(~x"//Merchant/Identifier/@merchantInstId"s), |
| 1200 |
|
|
| 1201 |
|
# Merchant Name |
| 1202 |
|
brand: doc |> xpath(~x"//Merchant/Name/@brand"s), |
| 1203 |
|
legal: doc |> xpath(~x"//Merchant/Name/@legal"s), |
| 1204 |
|
franchise: doc |> xpath(~x"//Merchant/Name/@franchise"s), |
| 1205 |
|
|
| 1206 |
|
# Merchant Ownership |
| 1207 |
|
ownership_type: doc |> xpath(~x"//Merchant/Ownership/@type"s), |
| 1208 |
|
|
| 1209 |
|
# Merchant Invoice |
| 1210 |
|
invoice_date: doc |> xpath(~x"//Merchant/Invoice/@date"s), |
| 1211 |
|
invoice_name: doc |> xpath(~x"//Merchant/Invoice/@name"s), |
| 1212 |
|
invoice_num: doc |> xpath(~x"//Merchant/Invoice/@num"s), |
| 1213 |
|
|
| 1214 |
|
# Payee Account details |
| 1215 |
|
payee_ifsc: doc |> xpath(~x"//Payee/Ac/Detail[@name='IFSC']/@value"s), |
| 1216 |
|
payee_ac_type: doc |> xpath(~x"//Payee/Ac/Detail[@name='ACTYPE']/@value"s), |
| 1217 |
|
payee_ac_num: doc |> xpath(~x"//Payee/Ac/Detail[@name='ACNUM']/@value"s), |
| 1218 |
|
|
| 1219 |
|
# Main Amount (from root level Amount tag, not nested ones) |
| 1220 |
|
amount: doc |> xpath(~x"//ReqPay/Amount/@value"s), |
| 1221 |
|
currency: doc |> xpath(~x"//ReqPay/Amount/@curr"s), |
| 1222 |
|
|
| 1223 |
|
# Payee Amount with splits (if present) |
| 1224 |
|
payee_amount: doc |> xpath(~x"//Payee/Amount/@value"s), |
| 1225 |
|
payee_currency: doc |> xpath(~x"//Payee/Amount/@curr"s), |
| 1226 |
|
base_amount: doc |> xpath(~x"//Payee/Amount/Split[@name='baseAmount']/@value"s), |
| 1227 |
|
base_curr: doc |> xpath(~x"//Payee/Amount/Split[@name='baseCurr']/@value"s), |
| 1228 |
|
fx_rate: doc |> xpath(~x"//Payee/Amount/Split[@name='FX']/@value"s), |
| 1229 |
|
markup: doc |> xpath(~x"//Payee/Amount/Split[@name='Mkup']/@value"s) |
| 1230 |
|
} |
| 1231 |
|
|
| 1232 |
|
# Filter out nil/empty values |
| 1233 |
:-( |
cleaned_data = data |
| 1234 |
:-( |
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) |
| 1235 |
|
|> Enum.into(%{}) |
| 1236 |
|
|
| 1237 |
|
{:ok, cleaned_data} |
| 1238 |
|
end |
| 1239 |
|
rescue |
| 1240 |
:-( |
e -> |
| 1241 |
:-( |
IO.inspect(e, label: "International ReqPay XML Parsing Error") |
| 1242 |
:-( |
{:error, "XML parsing failed: #{Exception.message(e)}"} |
| 1243 |
|
end |
| 1244 |
|
end |
| 1245 |
|
|
| 1246 |
|
defp validate_international_req_pay_fields(data) do |
| 1247 |
|
# Required fields as per NPCI specification for International ReqPay |
| 1248 |
:-( |
required_fields = [ |
| 1249 |
|
:org_id, :msg_id, :txn_id, :txn_type, # Basic transaction fields |
| 1250 |
|
:payer_addr, :payee_addr # Core payment fields |
| 1251 |
|
] |
| 1252 |
|
|
| 1253 |
:-( |
case validate_required_fields(data, required_fields) do |
| 1254 |
|
:ok -> |
| 1255 |
|
# Set defaults for missing optional fields |
| 1256 |
:-( |
data_with_defaults = data |
| 1257 |
|
|> Map.put_new(:version, "2.0") |
| 1258 |
|
|> Map.put_new(:timestamp, DateTime.utc_now() |> DateTime.to_iso8601()) |
| 1259 |
|
|> Map.put_new(:prod_type, "UPI") |
| 1260 |
|
|> Map.put_new(:purpose, "11") # Default to international purpose code |
| 1261 |
:-( |
|> Map.put_new(:amount, data[:payee_amount] || data[:payer_amount] || data[:base_amount] || "0.00") |
| 1262 |
:-( |
|> Map.put_new(:payee_amount, data[:base_amount] || data[:amount] || "0.00") # Use main amount as payee amount |
| 1263 |
:-( |
|> Map.put_new(:base_amount, data[:amount] || "0.00") # Use main amount as base amount |
| 1264 |
|
|> Map.put_new(:org_txn_id, data[:txn_id]) # Use txn_id as org_txn_id if missing |
| 1265 |
|
|> Map.put_new(:mid, derive_merchant_id(data)) # Generate merchant ID if not present |
| 1266 |
|
|> Map.put_new(:org_txn_id, data[:txn_id]) # Use txn_id as org_txn_id if missing |
| 1267 |
:-( |
|> Map.put_new(:base_amount, data[:amount] || "0.00") # Use main amount as base amount if missing |
| 1268 |
:-( |
|> Map.put_new(:base_curr, data[:currency] || "INR") # Use main currency as base currency |
| 1269 |
|
|> Map.put_new(:fx_rate, "1.0") # Default FX rate for domestic transactions |
| 1270 |
|
|> Map.put_new(:markup, "0.0") # Default markup |
| 1271 |
|
|> Map.put_new(:sp_risk_score, "0") # Default risk scores |
| 1272 |
|
|> Map.put_new(:npci_risk_score, "0") |
| 1273 |
|
|> Map.put_new(:purpose, "11") # International merchant credit |
| 1274 |
|
|> Map.put_new(:initiation_mode, "QR") |
| 1275 |
|
|> Map.put_new(:payer_type, "PERSON") |
| 1276 |
|
|> Map.put_new(:payee_type, "ENTITY") |
| 1277 |
|
|> Map.put_new(:payer_seq_num, "1") |
| 1278 |
|
|> Map.put_new(:payee_seq_num, "1") |
| 1279 |
|
|> Map.put_new(:payer_code, "0000") |
| 1280 |
|
|> Map.put_new(:expire_after, "30") # Default 30 minutes |
| 1281 |
|
|> Map.put_new(:qr_ver, "2.0") |
| 1282 |
|
|> Map.put_new(:qr_medium, "04") |
| 1283 |
:-( |
|> Map.put_new(:con_code, data[:con_code] || "IN") |
| 1284 |
|
|> Map.put_new(:payee_currency, "INR") |
| 1285 |
|
# --- Ensure these keys are always present --- |
| 1286 |
|
|> Map.put_new(:risk_scores, %{}) |
| 1287 |
|
|> Map.put_new(:qr_data, %{ |
| 1288 |
:-( |
expire_ts: data[:expire_ts] || "", |
| 1289 |
:-( |
ver: data[:qr_ver] || "", |
| 1290 |
:-( |
medium: data[:qr_medium] || "", |
| 1291 |
:-( |
stan: data[:stan] || "", |
| 1292 |
:-( |
ts: data[:qr_ts] || "" |
| 1293 |
|
}) |
| 1294 |
|
# Additional validation for specific values |
| 1295 |
:-( |
case validate_international_req_pay_values(data_with_defaults) do |
| 1296 |
:-( |
:ok -> {:ok, data_with_defaults} |
| 1297 |
:-( |
error -> error |
| 1298 |
|
end |
| 1299 |
:-( |
{:error, missing} -> {:error, "Missing required fields: #{Enum.join(missing, ", ")}"} |
| 1300 |
|
end |
| 1301 |
|
end |
| 1302 |
|
|
| 1303 |
|
defp validate_international_req_pay_values(data) do |
| 1304 |
:-( |
allowed_currencies = ["INR", "AED", "USD", "EUR", "SGD", "GBP"] # Add as needed |
| 1305 |
:-( |
cond do |
| 1306 |
:-( |
data[:version] && data[:version] != "2.0" -> |
| 1307 |
|
{:error, "API version must be '2.0' for international UPI"} |
| 1308 |
|
|
| 1309 |
:-( |
data[:purpose] && data[:purpose] != "11" -> |
| 1310 |
|
{:error, "Purpose must be '11' for international merchant credit"} |
| 1311 |
|
|
| 1312 |
:-( |
data[:txn_type] && data[:txn_type] not in ["CREDIT", "REVERSAL"] -> |
| 1313 |
|
{:error, "Transaction type must be 'CREDIT' or 'REVERSAL'"} |
| 1314 |
|
|
| 1315 |
:-( |
data[:prod_type] && data[:prod_type] != "UPI" -> |
| 1316 |
|
{:error, "Product type must be 'UPI' for international transactions"} |
| 1317 |
|
|
| 1318 |
:-( |
data[:cust_ref] && String.length(data[:cust_ref]) < 3 -> |
| 1319 |
|
{:error, "Customer reference must be at least 3 characters"} |
| 1320 |
|
|
| 1321 |
:-( |
data[:payer_code] && String.length(data[:payer_code]) != 4 -> |
| 1322 |
|
{:error, "Payer code must be exactly 4 digits"} |
| 1323 |
|
|
| 1324 |
:-( |
data[:expire_after] && validate_expire_after(data[:expire_after]) != :ok -> |
| 1325 |
|
{:error, "EXPIREAFTER must be between 1 and 64800 minutes"} |
| 1326 |
|
|
| 1327 |
:-( |
data[:payee_currency] && data[:payee_currency] not in allowed_currencies -> |
| 1328 |
|
{:error, "Payee currency must be one of: #{Enum.join(allowed_currencies, ", ")}"} |
| 1329 |
|
|
| 1330 |
:-( |
true -> :ok |
| 1331 |
|
end |
| 1332 |
|
end |
| 1333 |
|
|
| 1334 |
|
defp validate_expire_after(expire_str) do |
| 1335 |
:-( |
try do |
| 1336 |
:-( |
case Integer.parse(expire_str) do |
| 1337 |
:-( |
{minutes, ""} when minutes >= 1 and minutes <= 64800 -> :ok |
| 1338 |
:-( |
_ -> :error |
| 1339 |
|
end |
| 1340 |
|
rescue |
| 1341 |
:-( |
_ -> :error |
| 1342 |
|
end |
| 1343 |
|
end |
| 1344 |
|
|
| 1345 |
|
defp validate_req_val_qr_fields(data) do |
| 1346 |
|
# Required fields as per NPCI specification for ReqValQr |
| 1347 |
|
# For QR validation, we only need basic fields |
| 1348 |
:-( |
required_fields = [ |
| 1349 |
|
:org_id, :msg_id, # Head attributes (version and timestamp can be defaulted) |
| 1350 |
|
:qr_payload # The QR code payload to validate (mandatory) |
| 1351 |
|
] |
| 1352 |
|
|
| 1353 |
:-( |
case validate_required_fields(data, required_fields) do |
| 1354 |
|
:ok -> |
| 1355 |
|
# Set defaults for missing optional fields |
| 1356 |
:-( |
data_with_defaults = data |
| 1357 |
|
|> Map.put_new(:version, "2.0") |
| 1358 |
|
|> Map.put_new(:timestamp, DateTime.utc_now() |> DateTime.to_iso8601()) |
| 1359 |
|
|> Map.put_new(:txn_type, "IntlQr") |
| 1360 |
|
|> Map.put_new(:initiation_mode, "QR") |
| 1361 |
:-( |
|> Map.put_new(:purpose, data[:purpose] || "00") |
| 1362 |
:-( |
|> Map.put_new(:cust_ref, data[:cust_ref] || "000000000000") |
| 1363 |
|
|> Map.put_new(:payer_type, "PERSON") |
| 1364 |
|
|> Map.put_new(:payer_code, "0000") |
| 1365 |
|
|> Map.put_new(:seq_num, "1") |
| 1366 |
|
|
| 1367 |
|
# Additional validation for specific values if present |
| 1368 |
:-( |
case validate_req_val_qr_values(data_with_defaults) do |
| 1369 |
:-( |
:ok -> {:ok, data_with_defaults} |
| 1370 |
:-( |
error -> error |
| 1371 |
|
end |
| 1372 |
:-( |
{:error, missing} -> {:error, "Missing required fields: #{Enum.join(missing, ", ")}"} |
| 1373 |
|
end |
| 1374 |
|
end |
| 1375 |
|
|
| 1376 |
|
defp validate_req_val_qr_values(data) do |
| 1377 |
:-( |
cond do |
| 1378 |
:-( |
data[:version] && data[:version] != "2.0" -> |
| 1379 |
|
{:error, "API version must be '2.0'"} |
| 1380 |
|
|
| 1381 |
:-( |
data[:cust_ref] && String.length(data[:cust_ref]) < 3 -> |
| 1382 |
|
{:error, "Customer reference must be at least 3 characters"} |
| 1383 |
|
|
| 1384 |
:-( |
data[:payer_code] && String.length(data[:payer_code]) != 4 -> |
| 1385 |
|
{:error, "Payer code must be exactly 4 digits (use '0000' for individuals)"} |
| 1386 |
|
|
| 1387 |
:-( |
is_nil(data[:qr_payload]) or data[:qr_payload] == "" -> |
| 1388 |
|
{:error, "QrPayLoad is mandatory and cannot be empty"} |
| 1389 |
|
|
| 1390 |
:-( |
true -> :ok |
| 1391 |
|
end |
| 1392 |
|
end |
| 1393 |
|
|
| 1394 |
|
defp validate_req_pay_fields(data) do |
| 1395 |
|
required_fields = [:org_id, :msg_id, :timestamp, :txn_id, :amount, :payer_addr, :payee_addr] |
| 1396 |
|
|
| 1397 |
|
case validate_required_fields(data, required_fields) do |
| 1398 |
|
:ok -> {:ok, data} |
| 1399 |
|
{:error, missing} -> {:error, "Missing required fields: #{Enum.join(missing, ", ")}"} |
| 1400 |
|
end |
| 1401 |
|
end |
| 1402 |
|
|
| 1403 |
|
defp validate_req_chk_txn_fields(data) do |
| 1404 |
:-( |
required_fields = [:org_id, :msg_id, :timestamp, :org_txn_id] |
| 1405 |
|
|
| 1406 |
:-( |
case validate_required_fields(data, required_fields) do |
| 1407 |
:-( |
:ok -> {:ok, data} |
| 1408 |
:-( |
{:error, missing} -> {:error, "Missing required fields: #{Enum.join(missing, ", ")}"} |
| 1409 |
|
end |
| 1410 |
|
end |
| 1411 |
|
|
| 1412 |
|
defp validate_req_hbt_fields(data) do |
| 1413 |
|
required_fields = [:org_id, :msg_id, :timestamp] |
| 1414 |
|
|
| 1415 |
|
case validate_required_fields(data, required_fields) do |
| 1416 |
|
:ok -> {:ok, data} |
| 1417 |
|
{:error, missing} -> {:error, "Missing required fields: #{Enum.join(missing, ", ")}"} |
| 1418 |
|
end |
| 1419 |
|
end |
| 1420 |
|
|
| 1421 |
|
defp validate_required_fields(data, required_fields) do |
| 1422 |
:-( |
missing = Enum.filter(required_fields, fn field -> |
| 1423 |
:-( |
not Map.has_key?(data, field) or is_nil(data[field]) or data[field] == "" |
| 1424 |
|
end) |
| 1425 |
|
|
| 1426 |
:-( |
if Enum.empty?(missing) do |
| 1427 |
|
:ok |
| 1428 |
|
else |
| 1429 |
|
{:error, missing} |
| 1430 |
|
end |
| 1431 |
|
end |
| 1432 |
|
|
| 1433 |
|
defp get_timestamp do |
| 1434 |
|
# Return timestamp in Asia/Kolkata with +05:30 offset without fractional seconds |
| 1435 |
:-( |
case Application.get_env(:da_product_app, :use_timex_for_timestamps, true) do |
| 1436 |
|
true -> |
| 1437 |
|
# Use Timex if available for reliable timezone handling |
| 1438 |
|
DateTime.utc_now() |
| 1439 |
|
|> Timex.to_datetime("Asia/Kolkata") |
| 1440 |
:-( |
|> Timex.format!("{YYYY}-{0M}-{0D}T{h24}:{m}:{s}+05:30") |
| 1441 |
|
_ -> |
| 1442 |
|
DateTime.utc_now() |
| 1443 |
|
|> DateTime.shift_zone!("Etc/GMT-5") |
| 1444 |
:-( |
|> DateTime.to_iso8601() |
| 1445 |
|
end |
| 1446 |
|
end |
| 1447 |
|
|
| 1448 |
|
defp get_expiry_timestamp do |
| 1449 |
:-( |
case Application.get_env(:da_product_app, :use_timex_for_timestamps, true) do |
| 1450 |
|
true -> |
| 1451 |
|
DateTime.utc_now() |
| 1452 |
|
|> DateTime.add(300, :second) |
| 1453 |
|
|> Timex.to_datetime("Asia/Kolkata") |
| 1454 |
:-( |
|> Timex.format!("{YYYY}-{0M}-{0D}T{h24}:{m}:{s}+05:30") |
| 1455 |
|
_ -> |
| 1456 |
|
DateTime.utc_now() |
| 1457 |
|
|> DateTime.add(300, :second) |
| 1458 |
|
|> DateTime.shift_zone!("Etc/GMT-5") |
| 1459 |
:-( |
|> DateTime.to_iso8601() |
| 1460 |
|
end |
| 1461 |
|
end |
| 1462 |
|
|
| 1463 |
|
defp get_date do |
| 1464 |
|
Date.utc_today() |
| 1465 |
:-( |
|> Date.to_iso8601() |
| 1466 |
|
end |
| 1467 |
|
|
| 1468 |
|
defp generate_verification_token do |
| 1469 |
:-( |
"VT" <> (:crypto.strong_rand_bytes(16) |> Base.encode16()) |
| 1470 |
|
end |
| 1471 |
|
|
| 1472 |
|
defp generate_stan do |
| 1473 |
|
# System Trace Audit Number - 6 digit unique number |
| 1474 |
:-( |
:crypto.strong_rand_bytes(3) |> Base.encode16() |> String.slice(0, 6) |
| 1475 |
|
end |
| 1476 |
|
|
| 1477 |
|
@doc """ |
| 1478 |
|
Generate numeric sequence number for NPCI transactions |
| 1479 |
|
Returns a 6-digit numeric string for sequence numbering |
| 1480 |
|
""" |
| 1481 |
|
def generate_numeric_seq_num do |
| 1482 |
:-( |
:rand.uniform(999999) |> Integer.to_string() |> String.pad_leading(6, "0") |
| 1483 |
|
end |
| 1484 |
|
|
| 1485 |
|
defp generate_invoice_number do |
| 1486 |
:-( |
"INV" <> (:crypto.strong_rand_bytes(4) |> Base.encode16()) |
| 1487 |
|
end |
| 1488 |
|
|
| 1489 |
|
defp has_device_info?(request_data) do |
| 1490 |
:-( |
device_fields = [:mobile, :geocode, :location, :ip, :device_type, :device_id, :device_os, :device_app, :device_capability] |
| 1491 |
:-( |
Enum.any?(device_fields, fn field -> Map.get(request_data, field) not in [nil, ""] end) |
| 1492 |
|
end |
| 1493 |
|
|
| 1494 |
|
defp derive_merchant_id(data) do |
| 1495 |
|
# Generate merchant ID based on available data |
| 1496 |
:-( |
cond do |
| 1497 |
|
# If we have a payee address, derive from it |
| 1498 |
:-( |
data[:payee_addr] && String.contains?(data[:payee_addr], "@") -> |
| 1499 |
:-( |
[local_part, _domain] = String.split(data[:payee_addr], "@", parts: 2) |
| 1500 |
:-( |
"MERCHANT_" <> String.upcase(local_part) |> String.slice(0, 20) |
| 1501 |
|
|
| 1502 |
|
# If we have a business name from international structure |
| 1503 |
:-( |
data[:business_name] -> |
| 1504 |
:-( |
"MERCHANT_" <> (data[:business_name] |> String.upcase() |> String.replace(~r/[^A-Z0-9]/, "") |> String.slice(0, 15)) |
| 1505 |
|
|
| 1506 |
|
# If we have payee name |
| 1507 |
:-( |
data[:payee_name] -> |
| 1508 |
:-( |
"MERCHANT_" <> (data[:payee_name] |> String.upcase() |> String.replace(~r/[^A-Z0-9]/, "") |> String.slice(0, 15)) |
| 1509 |
|
|
| 1510 |
|
# Default fallback |
| 1511 |
:-( |
true -> |
| 1512 |
:-( |
"MERCHANT_" <> (:crypto.strong_rand_bytes(4) |> Base.encode16() |> String.slice(0, 10)) |
| 1513 |
|
end |
| 1514 |
|
end |
| 1515 |
|
|
| 1516 |
|
defp extract_merchant_type_from_qr(data) do |
| 1517 |
|
# Extract merchant type from QR payload or other data sources |
| 1518 |
|
# This follows NPCI merchant type standards |
| 1519 |
|
require Logger |
| 1520 |
|
|
| 1521 |
:-( |
cond do |
| 1522 |
|
# If merchant_type is already provided in data |
| 1523 |
:-( |
data[:merchant_type] && data[:merchant_type] != "" -> |
| 1524 |
:-( |
Logger.debug("Using provided merchant_type: #{data[:merchant_type]}") |
| 1525 |
:-( |
data[:merchant_type] |
| 1526 |
|
|
| 1527 |
|
# Try to derive from QR payload if present |
| 1528 |
:-( |
data[:qr_payload] && data[:qr_payload] != "" -> |
| 1529 |
:-( |
Logger.debug("Extracting mType from qr_payload: #{inspect(data[:qr_payload])}") |
| 1530 |
:-( |
extract_type_from_qr_string(data[:qr_payload]) |
| 1531 |
|
|
| 1532 |
|
# Try alternative QR field names |
| 1533 |
:-( |
data[:qr_string] && data[:qr_string] != "" -> |
| 1534 |
:-( |
Logger.debug("Extracting mType from qr_string: #{inspect(data[:qr_string])}") |
| 1535 |
:-( |
extract_type_from_qr_string(data[:qr_string]) |
| 1536 |
|
|
| 1537 |
|
# Check if there's a qr_data map |
| 1538 |
:-( |
data[:qr_data] && is_map(data[:qr_data]) && data[:qr_data][:qr_string] -> |
| 1539 |
:-( |
Logger.debug("Extracting mType from qr_data.qr_string: #{inspect(data[:qr_data][:qr_string])}") |
| 1540 |
:-( |
extract_type_from_qr_string(data[:qr_data][:qr_string]) |
| 1541 |
|
|
| 1542 |
|
# Derive from merchant category code |
| 1543 |
:-( |
data[:merchant_code] -> |
| 1544 |
:-( |
Logger.debug("Deriving mType from merchant_code: #{data[:merchant_code]}") |
| 1545 |
:-( |
derive_type_from_merchant_code(data[:merchant_code]) |
| 1546 |
|
|
| 1547 |
|
# Default merchant type |
| 1548 |
:-( |
true -> |
| 1549 |
:-( |
Logger.debug("Using default merchant type: SMALL") |
| 1550 |
|
"SMALL" |
| 1551 |
|
end |
| 1552 |
|
end |
| 1553 |
|
|
| 1554 |
|
defp extract_merchant_genre_from_qr(data) do |
| 1555 |
|
# Extract merchant genre from QR payload or other data sources |
| 1556 |
|
# This follows NPCI merchant categorization standards |
| 1557 |
:-( |
cond do |
| 1558 |
|
# If merchant_genre is already provided in data |
| 1559 |
:-( |
data[:merchant_genre] && data[:merchant_genre] != "" -> |
| 1560 |
:-( |
data[:merchant_genre] |
| 1561 |
|
|
| 1562 |
|
# Try to derive from QR payload if present |
| 1563 |
:-( |
data[:qr_payload] && String.contains?(data[:qr_payload], "merchantGenre") -> |
| 1564 |
:-( |
extract_genre_from_qr_string(data[:qr_payload]) |
| 1565 |
|
|
| 1566 |
|
# Derive from merchant type |
| 1567 |
:-( |
data[:merchant_type] -> |
| 1568 |
:-( |
derive_genre_from_merchant_type(data[:merchant_type]) |
| 1569 |
|
|
| 1570 |
|
# Default for international transactions |
| 1571 |
:-( |
true -> |
| 1572 |
|
"ONLINE" |
| 1573 |
|
end |
| 1574 |
|
end |
| 1575 |
|
|
| 1576 |
|
defp extract_genre_from_qr_string(qr_payload) do |
| 1577 |
|
# Parse QR payload to extract merchant genre |
| 1578 |
|
# QR format typically contains merchantGenre field |
| 1579 |
:-( |
case Regex.run(~r/merchantGenre[=:]([A-Z]+)/i, qr_payload) do |
| 1580 |
:-( |
[_, genre] -> String.upcase(genre) |
| 1581 |
:-( |
_ -> "ONLINE" # Default if not found |
| 1582 |
|
end |
| 1583 |
|
end |
| 1584 |
|
|
| 1585 |
|
defp derive_genre_from_merchant_type(merchant_type) do |
| 1586 |
|
# Map merchant type to genre as per NPCI standards |
| 1587 |
:-( |
case String.upcase(merchant_type) do |
| 1588 |
:-( |
"SMALL" -> "OFFLINE" |
| 1589 |
:-( |
"MEDIUM" -> "OFFLINE" |
| 1590 |
:-( |
"LARGE" -> "ONLINE" |
| 1591 |
:-( |
"ENTERPRISE" -> "ONLINE" |
| 1592 |
:-( |
_ -> "ONLINE" # Default |
| 1593 |
|
end |
| 1594 |
|
end |
| 1595 |
|
|
| 1596 |
|
defp extract_type_from_qr_string(qr_payload) do |
| 1597 |
|
# Parse QR payload to extract merchant type |
| 1598 |
|
# QR format can be: upi://pay?...&mType=VALUE or mType=VALUE in query parameters |
| 1599 |
|
# Also handle URL encoded formats and different separators |
| 1600 |
|
require Logger |
| 1601 |
:-( |
Logger.debug("Parsing QR payload for mType: #{inspect(qr_payload)}") |
| 1602 |
|
|
| 1603 |
:-( |
result = cond do |
| 1604 |
|
# Try standard query parameter format: mType=VALUE |
| 1605 |
|
match = Regex.run(~r/[&?]mType=([^&\s]+)/i, qr_payload) -> |
| 1606 |
:-( |
[_, merchant_type] = match |
| 1607 |
:-( |
mtype = String.upcase(String.trim(merchant_type)) |
| 1608 |
:-( |
Logger.debug("Found mType via query param: #{mtype}") |
| 1609 |
:-( |
mtype |
| 1610 |
|
|
| 1611 |
|
# Try colon separator: mType:VALUE |
| 1612 |
:-( |
match = Regex.run(~r/mType:([A-Za-z0-9]+)/i, qr_payload) -> |
| 1613 |
:-( |
[_, merchant_type] = match |
| 1614 |
:-( |
mtype = String.upcase(String.trim(merchant_type)) |
| 1615 |
:-( |
Logger.debug("Found mType via colon: #{mtype}") |
| 1616 |
:-( |
mtype |
| 1617 |
|
|
| 1618 |
|
# Try XML-like format: <mType>VALUE</mType> |
| 1619 |
:-( |
match = Regex.run(~r/<mType>([^<]+)<\/mType>/i, qr_payload) -> |
| 1620 |
:-( |
[_, merchant_type] = match |
| 1621 |
:-( |
mtype = String.upcase(String.trim(merchant_type)) |
| 1622 |
:-( |
Logger.debug("Found mType via XML: #{mtype}") |
| 1623 |
:-( |
mtype |
| 1624 |
|
|
| 1625 |
|
# Try simple format without separators: mTypeVALUE (less common) |
| 1626 |
:-( |
match = Regex.run(~r/mType([A-Z]+)/i, qr_payload) -> |
| 1627 |
:-( |
[_, merchant_type] = match |
| 1628 |
:-( |
mtype = String.upcase(String.trim(merchant_type)) |
| 1629 |
:-( |
Logger.debug("Found mType via simple: #{mtype}") |
| 1630 |
:-( |
mtype |
| 1631 |
|
|
| 1632 |
|
# Default fallback |
| 1633 |
:-( |
true -> |
| 1634 |
:-( |
Logger.debug("No mType found in QR payload, using default: SMALL") |
| 1635 |
|
"SMALL" |
| 1636 |
|
end |
| 1637 |
|
|
| 1638 |
:-( |
Logger.debug("Final extracted mType: #{result}") |
| 1639 |
:-( |
result |
| 1640 |
|
end |
| 1641 |
|
|
| 1642 |
|
defp derive_type_from_merchant_code(merchant_code) do |
| 1643 |
|
# Map merchant category code to merchant type as per NPCI standards |
| 1644 |
|
# This is based on standard merchant category codes (MCC) |
| 1645 |
:-( |
case merchant_code do |
| 1646 |
:-( |
code when code in ["5812", "5813", "5814"] -> "SMALL" # Restaurants, bars |
| 1647 |
:-( |
code when code in ["5411", "5499"] -> "SMALL" # Grocery stores |
| 1648 |
:-( |
code when code in ["5311", "5399"] -> "MEDIUM" # Department stores |
| 1649 |
:-( |
code when code in ["5541", "5542"] -> "MEDIUM" # Service stations |
| 1650 |
:-( |
code when code in ["5999", "7399"] -> "LARGE" # Miscellaneous, business services |
| 1651 |
:-( |
_ -> "SMALL" # Default |
| 1652 |
|
end |
| 1653 |
|
end |
| 1654 |
|
|
| 1655 |
|
|
| 1656 |
|
defp extract_location_from_qr_string(qr_payload) do |
| 1657 |
|
# Parse QR payload to extract merchant location from mLoc= parameter |
| 1658 |
|
# QR format typically contains mLoc=VALUE in query parameters |
| 1659 |
|
require Logger |
| 1660 |
:-( |
Logger.debug("Parsing QR payload for mLoc: #{inspect(qr_payload)}") |
| 1661 |
|
|
| 1662 |
:-( |
result = cond do |
| 1663 |
|
# Try standard query parameter format: mLoc=VALUE |
| 1664 |
|
match = Regex.run(~r/[&?]mLoc=([^&\s]+)/i, qr_payload) -> |
| 1665 |
:-( |
[_, location] = match |
| 1666 |
:-( |
loc = String.trim(location) |
| 1667 |
:-( |
Logger.debug("Found mLoc via query param: #{loc}") |
| 1668 |
:-( |
loc |
| 1669 |
|
|
| 1670 |
|
# Try colon separator: mLoc:VALUE |
| 1671 |
:-( |
match = Regex.run(~r/mLoc:([^:&\s]+)/i, qr_payload) -> |
| 1672 |
:-( |
[_, location] = match |
| 1673 |
:-( |
loc = String.trim(location) |
| 1674 |
:-( |
Logger.debug("Found mLoc via colon: #{loc}") |
| 1675 |
:-( |
loc |
| 1676 |
|
|
| 1677 |
|
# Try XML-like format: <mLoc>VALUE</mLoc> |
| 1678 |
:-( |
match = Regex.run(~r/<mLoc>([^<]+)<\/mLoc>/i, qr_payload) -> |
| 1679 |
:-( |
[_, location] = match |
| 1680 |
:-( |
loc = String.trim(location) |
| 1681 |
:-( |
Logger.debug("Found mLoc via XML: #{loc}") |
| 1682 |
:-( |
loc |
| 1683 |
|
|
| 1684 |
|
# Try simple format: mLocVALUE (less common) |
| 1685 |
:-( |
match = Regex.run(~r/mLoc([A-Za-z0-9]+)/i, qr_payload) -> |
| 1686 |
:-( |
[_, location] = match |
| 1687 |
:-( |
loc = String.trim(location) |
| 1688 |
:-( |
Logger.debug("Found mLoc via simple: #{loc}") |
| 1689 |
:-( |
loc |
| 1690 |
|
|
| 1691 |
|
# Default fallback |
| 1692 |
:-( |
true -> |
| 1693 |
:-( |
Logger.debug("No mLoc found in QR payload, using default: International") |
| 1694 |
|
"International" |
| 1695 |
|
end |
| 1696 |
|
|
| 1697 |
:-( |
Logger.debug("Final extracted mLoc: #{result}") |
| 1698 |
:-( |
result |
| 1699 |
|
end |
| 1700 |
|
|
| 1701 |
|
defp extract_mid_from_qr(data) do |
| 1702 |
|
# Extract merchant ID (mid) from QR payload |
| 1703 |
|
# QR format typically contains mid=VALUE in query parameters |
| 1704 |
|
require Logger |
| 1705 |
|
|
| 1706 |
:-( |
qr_payload = case data do |
| 1707 |
:-( |
%{qr_payload: payload} when is_binary(payload) -> payload |
| 1708 |
:-( |
%{qr_string: payload} when is_binary(payload) -> payload |
| 1709 |
:-( |
_ -> "" |
| 1710 |
|
end |
| 1711 |
|
|
| 1712 |
:-( |
Logger.debug("Parsing QR payload for mid: #{inspect(qr_payload)}") |
| 1713 |
|
|
| 1714 |
:-( |
result = cond do |
| 1715 |
|
# Try standard query parameter format: mid=VALUE |
| 1716 |
|
match = Regex.run(~r/[&?]mid=([^&\s]+)/i, qr_payload) -> |
| 1717 |
:-( |
[_, mid] = match |
| 1718 |
:-( |
merchant_id = URI.decode(String.trim(mid)) |
| 1719 |
:-( |
Logger.debug("Found mid via query param: #{merchant_id}") |
| 1720 |
:-( |
merchant_id |
| 1721 |
|
|
| 1722 |
|
# Try colon separator: mid:VALUE |
| 1723 |
:-( |
match = Regex.run(~r/mid:([^:&\s]+)/i, qr_payload) -> |
| 1724 |
:-( |
[_, mid] = match |
| 1725 |
:-( |
merchant_id = URI.decode(String.trim(mid)) |
| 1726 |
:-( |
Logger.debug("Found mid via colon: #{merchant_id}") |
| 1727 |
:-( |
merchant_id |
| 1728 |
|
|
| 1729 |
|
# Try XML-like format: <mid>VALUE</mid> |
| 1730 |
:-( |
match = Regex.run(~r/<mid>([^<]+)<\/mid>/i, qr_payload) -> |
| 1731 |
:-( |
[_, mid] = match |
| 1732 |
:-( |
merchant_id = URI.decode(String.trim(mid)) |
| 1733 |
:-( |
Logger.debug("Found mid via XML: #{merchant_id}") |
| 1734 |
:-( |
merchant_id |
| 1735 |
|
|
| 1736 |
|
# Default fallback - return nil so we can use database value or default |
| 1737 |
:-( |
true -> |
| 1738 |
:-( |
Logger.debug("No mid found in QR payload") |
| 1739 |
|
nil |
| 1740 |
|
end |
| 1741 |
|
|
| 1742 |
:-( |
Logger.debug("Final extracted mid: #{result}") |
| 1743 |
:-( |
result |
| 1744 |
|
end |
| 1745 |
|
|
| 1746 |
|
defp extract_mtid_from_qr(data) do |
| 1747 |
|
# Extract terminal ID (mtid) from QR payload for mode 17 (mandate) |
| 1748 |
|
# QR format typically contains mtid=VALUE in query parameters |
| 1749 |
|
require Logger |
| 1750 |
|
|
| 1751 |
:-( |
qr_payload = case data do |
| 1752 |
:-( |
%{qr_payload: payload} when is_binary(payload) -> payload |
| 1753 |
:-( |
%{qr_string: payload} when is_binary(payload) -> payload |
| 1754 |
:-( |
_ -> "" |
| 1755 |
|
end |
| 1756 |
|
|
| 1757 |
:-( |
Logger.debug("Parsing QR payload for mtid: #{inspect(qr_payload)}") |
| 1758 |
|
|
| 1759 |
:-( |
result = cond do |
| 1760 |
|
# Try standard query parameter format: mtid=VALUE |
| 1761 |
|
match = Regex.run(~r/[&?]mtid=([^&\s]+)/i, qr_payload) -> |
| 1762 |
:-( |
[_, mtid] = match |
| 1763 |
:-( |
tid = URI.decode(String.trim(mtid)) |
| 1764 |
:-( |
Logger.debug("Found mtid via query param: #{tid}") |
| 1765 |
:-( |
tid |
| 1766 |
|
|
| 1767 |
|
# Try colon separator: mtid:VALUE |
| 1768 |
:-( |
match = Regex.run(~r/mtid:([^:&\s]+)/i, qr_payload) -> |
| 1769 |
:-( |
[_, mtid] = match |
| 1770 |
:-( |
tid = URI.decode(String.trim(mtid)) |
| 1771 |
:-( |
Logger.debug("Found mtid via colon: #{tid}") |
| 1772 |
:-( |
tid |
| 1773 |
|
|
| 1774 |
|
# Default fallback - return nil so we can use database value or default |
| 1775 |
:-( |
true -> |
| 1776 |
:-( |
Logger.debug("No mtid found in QR payload") |
| 1777 |
|
nil |
| 1778 |
|
end |
| 1779 |
|
|
| 1780 |
:-( |
Logger.debug("Final extracted mtid: #{result}") |
| 1781 |
:-( |
result |
| 1782 |
|
end |
| 1783 |
|
|
| 1784 |
|
defp extract_msid_from_qr(data) do |
| 1785 |
|
# Extract store ID (msid) from QR payload for merchant transactions |
| 1786 |
|
# QR format typically contains msid=VALUE in query parameters |
| 1787 |
|
require Logger |
| 1788 |
|
|
| 1789 |
:-( |
qr_payload = case data do |
| 1790 |
:-( |
%{qr_payload: payload} when is_binary(payload) -> payload |
| 1791 |
:-( |
%{qr_string: payload} when is_binary(payload) -> payload |
| 1792 |
:-( |
_ -> "" |
| 1793 |
|
end |
| 1794 |
|
|
| 1795 |
:-( |
Logger.debug("Parsing QR payload for msid: #{inspect(qr_payload)}") |
| 1796 |
|
|
| 1797 |
:-( |
result = cond do |
| 1798 |
|
# Try standard query parameter format: msid=VALUE |
| 1799 |
|
match = Regex.run(~r/[&?]msid=([^&\s]+)/i, qr_payload) -> |
| 1800 |
:-( |
[_, msid] = match |
| 1801 |
:-( |
sid = URI.decode(String.trim(msid)) |
| 1802 |
:-( |
Logger.debug("Found msid via query param: #{sid}") |
| 1803 |
:-( |
sid |
| 1804 |
|
|
| 1805 |
|
# Try colon separator: msid:VALUE |
| 1806 |
:-( |
match = Regex.run(~r/msid:([^:&\s]+)/i, qr_payload) -> |
| 1807 |
:-( |
[_, msid] = match |
| 1808 |
:-( |
sid = URI.decode(String.trim(msid)) |
| 1809 |
:-( |
Logger.debug("Found msid via colon: #{sid}") |
| 1810 |
:-( |
sid |
| 1811 |
|
|
| 1812 |
|
# Try sid parameter as alternative |
| 1813 |
:-( |
match = Regex.run(~r/[&?]sid=([^&\s]+)/i, qr_payload) -> |
| 1814 |
:-( |
[_, sid] = match |
| 1815 |
:-( |
store_id = URI.decode(String.trim(sid)) |
| 1816 |
:-( |
Logger.debug("Found sid via query param: #{store_id}") |
| 1817 |
:-( |
store_id |
| 1818 |
|
|
| 1819 |
|
# Default fallback - return nil so we can use database value or default |
| 1820 |
:-( |
true -> |
| 1821 |
:-( |
Logger.debug("No msid found in QR payload") |
| 1822 |
|
nil |
| 1823 |
|
end |
| 1824 |
|
|
| 1825 |
:-( |
Logger.debug("Final extracted msid: #{result}") |
| 1826 |
:-( |
result |
| 1827 |
|
end |
| 1828 |
|
|
| 1829 |
|
defp extract_invoice_number_from_qr(data) do |
| 1830 |
|
# Extract invoice number from QR payload for mode 17 (mandate) |
| 1831 |
|
# QR format typically contains invoiceNo=VALUE in query parameters |
| 1832 |
|
require Logger |
| 1833 |
|
|
| 1834 |
:-( |
qr_payload = case data do |
| 1835 |
:-( |
%{qr_payload: payload} when is_binary(payload) -> payload |
| 1836 |
:-( |
%{qr_string: payload} when is_binary(payload) -> payload |
| 1837 |
:-( |
_ -> "" |
| 1838 |
|
end |
| 1839 |
|
|
| 1840 |
:-( |
Logger.debug("Parsing QR payload for invoiceNo: #{inspect(qr_payload)}") |
| 1841 |
|
|
| 1842 |
:-( |
result = cond do |
| 1843 |
|
# Try standard query parameter format: invoiceNo=VALUE |
| 1844 |
|
match = Regex.run(~r/[&?]invoiceNo=([^&\s]+)/i, qr_payload) -> |
| 1845 |
:-( |
[_, invoice_no] = match |
| 1846 |
:-( |
inv = URI.decode(String.trim(invoice_no)) |
| 1847 |
:-( |
Logger.debug("Found invoiceNo via query param: #{inv}") |
| 1848 |
:-( |
inv |
| 1849 |
|
|
| 1850 |
|
# Try colon separator: invoiceNo:VALUE |
| 1851 |
:-( |
match = Regex.run(~r/invoiceNo:([^:&\s]+)/i, qr_payload) -> |
| 1852 |
:-( |
[_, invoice_no] = match |
| 1853 |
:-( |
inv = URI.decode(String.trim(invoice_no)) |
| 1854 |
:-( |
Logger.debug("Found invoiceNo via colon: #{inv}") |
| 1855 |
:-( |
inv |
| 1856 |
|
|
| 1857 |
|
# Default fallback - return nil so we can use generate_invoice_number() |
| 1858 |
:-( |
true -> |
| 1859 |
:-( |
Logger.debug("No invoiceNo found in QR payload") |
| 1860 |
|
nil |
| 1861 |
|
end |
| 1862 |
|
|
| 1863 |
:-( |
Logger.debug("Final extracted invoiceNo: #{result}") |
| 1864 |
:-( |
result |
| 1865 |
|
end |
| 1866 |
|
|
| 1867 |
|
defp extract_vpa_from_qr_string(qr_payload) do |
| 1868 |
|
# Parse QR payload to extract VPA (Virtual Payment Address) |
| 1869 |
|
# QR format can be: upi://pay?pa=VPA&... or pa=VPA in query parameters |
| 1870 |
|
require Logger |
| 1871 |
:-( |
Logger.debug("Parsing QR payload for VPA: #{inspect(qr_payload)}") |
| 1872 |
|
|
| 1873 |
:-( |
result = cond do |
| 1874 |
|
# Try standard query parameter format: pa=VPA |
| 1875 |
|
match = Regex.run(~r/[&?]pa=([^&\s]+)/i, qr_payload) -> |
| 1876 |
:-( |
[_, vpa] = match |
| 1877 |
:-( |
vpa = String.trim(vpa) |
| 1878 |
:-( |
Logger.debug("Found VPA via query param: #{vpa}") |
| 1879 |
:-( |
vpa |
| 1880 |
|
|
| 1881 |
|
# Try XML-like format: <pa>VPA</pa> |
| 1882 |
:-( |
match = Regex.run(~r/<pa>([^<]+)<\/pa>/i, qr_payload) -> |
| 1883 |
:-( |
[_, vpa] = match |
| 1884 |
:-( |
vpa = String.trim(vpa) |
| 1885 |
:-( |
Logger.debug("Found VPA via XML: #{vpa}") |
| 1886 |
:-( |
vpa |
| 1887 |
|
|
| 1888 |
|
# Try colon separator: pa:VPA |
| 1889 |
:-( |
match = Regex.run(~r/pa:([^:&\s]+)/i, qr_payload) -> |
| 1890 |
:-( |
[_, vpa] = match |
| 1891 |
:-( |
vpa = String.trim(vpa) |
| 1892 |
:-( |
Logger.debug("Found VPA via colon: #{vpa}") |
| 1893 |
:-( |
vpa |
| 1894 |
|
|
| 1895 |
|
# Try simple format: paVPA (less common) |
| 1896 |
:-( |
match = Regex.run(~r/pa([a-zA-Z0-9@.-]+)/i, qr_payload) -> |
| 1897 |
:-( |
[_, vpa] = match |
| 1898 |
:-( |
vpa = String.trim(vpa) |
| 1899 |
:-( |
Logger.debug("Found VPA via simple: #{vpa}") |
| 1900 |
:-( |
vpa |
| 1901 |
|
|
| 1902 |
|
# Default fallback - extract anything that looks like a VPA |
| 1903 |
:-( |
match = Regex.run(~r/([a-zA-Z0-9.-]+@[a-zA-Z0-9.-]+)/i, qr_payload) -> |
| 1904 |
:-( |
[_, vpa] = match |
| 1905 |
:-( |
vpa = String.trim(vpa) |
| 1906 |
:-( |
Logger.debug("Found VPA via email pattern: #{vpa}") |
| 1907 |
:-( |
vpa |
| 1908 |
|
|
| 1909 |
|
# Last resort fallback |
| 1910 |
:-( |
true -> |
| 1911 |
:-( |
Logger.warning("No VPA found in QR payload, using default") |
| 1912 |
|
"merchant@defaultbank" |
| 1913 |
|
end |
| 1914 |
|
|
| 1915 |
:-( |
Logger.debug("Final extracted VPA: #{result}") |
| 1916 |
:-( |
result |
| 1917 |
|
end |
| 1918 |
|
|
| 1919 |
|
|
| 1920 |
|
defp extract_vpa_from_qr_string(qr_payload) do |
| 1921 |
|
# Parse QR payload to extract VPA (Virtual Payment Address) |
| 1922 |
|
# QR format can be: upi://pay?pa=VPA&... or pa=VPA in query parameters |
| 1923 |
|
require Logger |
| 1924 |
:-( |
Logger.debug("Parsing QR payload for VPA: #{inspect(qr_payload)}") |
| 1925 |
|
|
| 1926 |
:-( |
result = cond do |
| 1927 |
|
# Try standard query parameter format: pa=VPA |
| 1928 |
|
match = Regex.run(~r/[&?]pa=([^&\s]+)/i, qr_payload) -> |
| 1929 |
:-( |
[_, vpa] = match |
| 1930 |
:-( |
vpa = String.trim(vpa) |
| 1931 |
:-( |
Logger.debug("Found VPA via query param: #{vpa}") |
| 1932 |
:-( |
vpa |
| 1933 |
|
|
| 1934 |
|
# Try XML-like format: <pa>VPA</pa> |
| 1935 |
:-( |
match = Regex.run(~r/<pa>([^<]+)<\/pa>/i, qr_payload) -> |
| 1936 |
:-( |
[_, vpa] = match |
| 1937 |
:-( |
vpa = String.trim(vpa) |
| 1938 |
:-( |
Logger.debug("Found VPA via XML: #{vpa}") |
| 1939 |
:-( |
vpa |
| 1940 |
|
|
| 1941 |
|
# Try colon separator: pa:VPA |
| 1942 |
:-( |
match = Regex.run(~r/pa:([^:&\s]+)/i, qr_payload) -> |
| 1943 |
:-( |
[_, vpa] = match |
| 1944 |
:-( |
vpa = String.trim(vpa) |
| 1945 |
:-( |
Logger.debug("Found VPA via colon: #{vpa}") |
| 1946 |
:-( |
vpa |
| 1947 |
|
|
| 1948 |
|
# Try simple format: paVPA (less common) |
| 1949 |
:-( |
match = Regex.run(~r/pa([a-zA-Z0-9@.-]+)/i, qr_payload) -> |
| 1950 |
:-( |
[_, vpa] = match |
| 1951 |
:-( |
vpa = String.trim(vpa) |
| 1952 |
:-( |
Logger.debug("Found VPA via simple: #{vpa}") |
| 1953 |
:-( |
vpa |
| 1954 |
|
|
| 1955 |
|
# Default fallback - extract anything that looks like a VPA |
| 1956 |
:-( |
match = Regex.run(~r/([a-zA-Z0-9.-]+@[a-zA-Z0-9.-]+)/i, qr_payload) -> |
| 1957 |
:-( |
[_, vpa] = match |
| 1958 |
:-( |
vpa = String.trim(vpa) |
| 1959 |
:-( |
Logger.debug("Found VPA via email pattern: #{vpa}") |
| 1960 |
:-( |
vpa |
| 1961 |
|
|
| 1962 |
|
# Last resort fallback |
| 1963 |
:-( |
true -> |
| 1964 |
:-( |
Logger.warning("No VPA found in QR payload, using default") |
| 1965 |
|
"merchant@defaultbank" |
| 1966 |
|
end |
| 1967 |
|
|
| 1968 |
:-( |
Logger.debug("Final extracted VPA: #{result}") |
| 1969 |
:-( |
result |
| 1970 |
|
end |
| 1971 |
|
|
| 1972 |
|
defp extract_merchant_location_from_qr(data) do |
| 1973 |
|
# Extract merchant location from QR payload or other data sources |
| 1974 |
|
# This follows NPCI merchant location standards |
| 1975 |
:-( |
cond do |
| 1976 |
|
# If merchant_loc is already provided in data |
| 1977 |
:-( |
data[:merchant_loc] && data[:merchant_loc] != "" -> |
| 1978 |
:-( |
data[:merchant_loc] |
| 1979 |
|
|
| 1980 |
|
# Try to derive from QR payload if present |
| 1981 |
:-( |
data[:qr_payload] && data[:qr_payload] != "" -> |
| 1982 |
:-( |
extract_location_from_qr_string(data[:qr_payload]) |
| 1983 |
|
|
| 1984 |
|
# Try alternative QR field names |
| 1985 |
:-( |
data[:qr_string] && data[:qr_string] != "" -> |
| 1986 |
:-( |
extract_location_from_qr_string(data[:qr_string]) |
| 1987 |
|
|
| 1988 |
|
# Derive from country code |
| 1989 |
:-( |
data[:con_code] -> |
| 1990 |
:-( |
derive_location_from_country_code(data[:con_code]) |
| 1991 |
|
|
| 1992 |
|
# Default for international transactions |
| 1993 |
:-( |
true -> |
| 1994 |
|
"IN" |
| 1995 |
|
end |
| 1996 |
|
end |
| 1997 |
|
|
| 1998 |
|
defp derive_location_from_country_code(con_code) do |
| 1999 |
|
# Map country code to location as per NPCI standards |
| 2000 |
:-( |
case String.upcase(con_code) do |
| 2001 |
:-( |
"IN" -> "India" |
| 2002 |
:-( |
"AE" -> "Dubai" |
| 2003 |
:-( |
"SG" -> "Singapore" |
| 2004 |
:-( |
"US" -> "USA" |
| 2005 |
:-( |
"GB" -> "UK" |
| 2006 |
:-( |
"DE" -> "Germany" |
| 2007 |
:-( |
"FR" -> "France" |
| 2008 |
:-( |
_ -> "International" # Default |
| 2009 |
|
end |
| 2010 |
|
end |
| 2011 |
|
|
| 2012 |
|
@doc """ |
| 2013 |
|
Matches NPCI expected formatting (attributes inline, consistent indentation). |
| 2014 |
|
""" |
| 2015 |
|
def generate_credit_response(response_data) do |
| 2016 |
:-( |
xml_content = """ |
| 2017 |
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?> |
| 2018 |
|
<upi:RespPay xmlns:upi="http://npci.org/upi/schema/"> |
| 2019 |
:-( |
<Head msgId="#{generate_fixed_length_msg_id(Map.get(response_data, :msg_id))}" |
| 2020 |
|
orgId="#{"MER101"}" |
| 2021 |
:-( |
prodType="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :prod_type, "UPI")))}" |
| 2022 |
:-( |
ts="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :head_ts, get_timestamp())))}" |
| 2023 |
:-( |
ver="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :ver, "2.0")))}"/> |
| 2024 |
:-( |
<Txn custRef="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :cust_ref, "")))}" |
| 2025 |
:-( |
id="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :txn_id, "")))}" |
| 2026 |
:-( |
initiationMode="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :initiation_mode, "01")))}" |
| 2027 |
:-( |
note="#{escape_xml_entities(validate_and_preserve_note(Map.get(response_data, :note)))}" |
| 2028 |
:-( |
orgTxnId="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :org_txn_id, "")))}" |
| 2029 |
:-( |
purpose="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :purpose, "11")))}" |
| 2030 |
:-( |
refCategory="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :ref_category, "00")))}" |
| 2031 |
:-( |
refId="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :ref_id, "")))}" |
| 2032 |
:-( |
refUrl="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :ref_url, "")))}" |
| 2033 |
:-( |
seqNum="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :seq_num, generate_numeric_seq_num())))}" |
| 2034 |
:-( |
subType="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :sub_type, "PAY")))}" |
| 2035 |
:-( |
ts="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :txn_ts, get_timestamp())))}" |
| 2036 |
:-( |
type="#{escape_xml_entities(sanitize_attr(Map.get(response_data, :txn_type, "CREDIT")))}"> |
| 2037 |
:-( |
<approvalNum>#{escape_xml_entities(sanitize_attr(Map.get(response_data, :approval_num, generate_approval_num())))}</approvalNum> |
| 2038 |
:-( |
#{generate_risk_scores_xml(Map.get(response_data, :risk_scores))} |
| 2039 |
:-( |
#{generate_qr_xml(Map.get(response_data, :qr_data))} |
| 2040 |
|
</Txn> |
| 2041 |
:-( |
<Ref IFSC="#{sanitize_attr(response_data.payee_ref.ifsc)}" |
| 2042 |
:-( |
acNum="#{sanitize_attr(response_data.payee_ref.ac_num)}" |
| 2043 |
:-( |
accType="#{sanitize_attr(response_data.payee_ref.acc_type)}" |
| 2044 |
:-( |
addr="#{sanitize_attr(response_data.payee_ref.addr)}" |
| 2045 |
:-( |
code="#{sanitize_attr(response_data.payee_ref.code)}" |
| 2046 |
:-( |
orgAmount="#{sanitize_attr(response_data.payee_ref.org_amount)}" |
| 2047 |
:-( |
regName="#{sanitize_attr(response_data.payee_ref.reg_name)}" |
| 2048 |
:-( |
respCode="#{sanitize_attr(response_data.payee_ref.resp_code)}" |
| 2049 |
:-( |
seqNum="#{sanitize_attr(response_data.payee_ref.seq_num)}" |
| 2050 |
:-( |
settAmount="#{sanitize_attr(response_data.payee_ref.sett_amount)}" |
| 2051 |
:-( |
settCurrency="#{sanitize_attr(response_data.payee_ref.sett_currency)}" |
| 2052 |
:-( |
type="#{sanitize_attr(response_data.payee_ref.type)}"/> |
| 2053 |
|
</Resp> |
| 2054 |
|
""" |
| 2055 |
|
|
| 2056 |
|
|
| 2057 |
|
# Generate the Signature block |
| 2058 |
:-( |
signature_block = generate_signature_block(xml_content) |
| 2059 |
|
|
| 2060 |
|
# # Combine XML content with signature block and closing tag |
| 2061 |
:-( |
complete_xml = xml_content <> signature_block <> "\n</upi:RespPay>" |
| 2062 |
|
{:ok, complete_xml} |
| 2063 |
|
end |
| 2064 |
|
|
| 2065 |
|
# Helper to sanitize attribute values: trims whitespace and converts nil to empty string |
| 2066 |
:-( |
defp sanitize_attr(nil), do: "" |
| 2067 |
:-( |
defp sanitize_attr(value) when is_binary(value), do: String.trim(value) |
| 2068 |
:-( |
defp sanitize_attr(value), do: value |> to_string() |> String.trim() |
| 2069 |
|
|
| 2070 |
|
# Generate RiskScores section |
| 2071 |
|
defp generate_risk_scores_xml(risk_scores) when is_map(risk_scores) do |
| 2072 |
:-( |
""" |
| 2073 |
|
<RiskScores> |
| 2074 |
:-( |
<Score provider="NPCI" type="TXNRISK" value="#{risk_scores["value"] || "00030"}"/> |
| 2075 |
|
</RiskScores> |
| 2076 |
|
""" |
| 2077 |
|
end |
| 2078 |
|
|
| 2079 |
:-( |
defp generate_risk_scores_xml(_), do: "" |
| 2080 |
|
|
| 2081 |
|
defp generate_qr_xml(qr_data) when is_map(qr_data) do |
| 2082 |
:-( |
""" |
| 2083 |
:-( |
<QR expireTs="#{Map.get(qr_data, :expire_ts, "")}" |
| 2084 |
:-( |
qVer="#{Map.get(qr_data, :ver, "")}" |
| 2085 |
:-( |
qrMedium="#{Map.get(qr_data, :medium, "")}" |
| 2086 |
:-( |
stan="#{Map.get(qr_data, :stan, "")}" |
| 2087 |
:-( |
ts="#{Map.get(qr_data, :ts, "")}"/> |
| 2088 |
|
""" |
| 2089 |
|
end |
| 2090 |
|
|
| 2091 |
:-( |
defp generate_qr_xml(_), do: "" |
| 2092 |
|
|
| 2093 |
|
@doc """ |
| 2094 |
|
Get UPI error code description |
| 2095 |
|
""" |
| 2096 |
:-( |
def get_error_description(code), do: Map.get(@upi_error_codes, code, "Unknown error") |
| 2097 |
|
|
| 2098 |
|
@doc """ |
| 2099 |
|
Validate UPI error code |
| 2100 |
|
""" |
| 2101 |
:-( |
def valid_error_code?(code), do: Map.has_key?(@upi_error_codes, code) |
| 2102 |
|
|
| 2103 |
|
# Helper function to get PSP organization ID from configuration |
| 2104 |
|
defp get_psp_org_id do |
| 2105 |
:-( |
Application.get_env(:da_product_app, :psp_org_id, "MERCURY") |
| 2106 |
|
end |
| 2107 |
|
|
| 2108 |
|
# Helper function to escape XML entities in QR payload |
| 2109 |
|
defp escape_xml_entities(text) when is_binary(text) do |
| 2110 |
|
text |
| 2111 |
|
|> String.replace("&", "&") |
| 2112 |
|
|> String.replace("<", "<") |
| 2113 |
|
|> String.replace(">", ">") |
| 2114 |
|
|> String.replace("\"", """) |
| 2115 |
:-( |
|> String.replace("'", "'") |
| 2116 |
|
end |
| 2117 |
:-( |
defp escape_xml_entities(nil), do: "" |
| 2118 |
:-( |
defp escape_xml_entities(other), do: to_string(other) |
| 2119 |
|
|
| 2120 |
|
# Helper function to generate a fixed length message ID (NPCI requires 35 chars) |
| 2121 |
|
# Ensures a valid MSGID is always returned: if input is missing or invalid we generate |
| 2122 |
|
# a deterministic/sample-like MSGID that complies with length and format expectations. |
| 2123 |
|
defp generate_fixed_length_msg_id(msg_id) when is_binary(msg_id) do |
| 2124 |
|
# configurable org prefix, default "MER" - enforce 3-char uppercase prefix |
| 2125 |
:-( |
prefix_clean = |
| 2126 |
|
Application.get_env(:da_product_app, :psp_org_prefix, "MER") |
| 2127 |
:-( |
|> to_string() |
| 2128 |
|
|> String.replace(~r/[^A-Za-z0-9]/, "") |
| 2129 |
|
|> String.slice(0, 3) |
| 2130 |
|
|> String.upcase() |
| 2131 |
|
|
| 2132 |
|
# keep only hex chars from input and normalize to lowercase for consistent |
| 2133 |
|
# appearance |
| 2134 |
:-( |
cleaned = |
| 2135 |
|
msg_id |
| 2136 |
|
|> String.replace(~r/[^A-Fa-f0-9]/, "") |
| 2137 |
|
|> String.downcase() |
| 2138 |
|
|> String.trim() |
| 2139 |
|
|
| 2140 |
|
# desired total length |
| 2141 |
:-( |
total = 35 |
| 2142 |
:-( |
body_len = total - String.length(prefix_clean) |
| 2143 |
|
|
| 2144 |
:-( |
cond do |
| 2145 |
|
# If prefix alone is already too long, truncate prefix to exact length |
| 2146 |
|
body_len <= 0 -> |
| 2147 |
:-( |
prefix_clean |> String.slice(0, total) |
| 2148 |
|
|
| 2149 |
:-( |
true -> |
| 2150 |
|
# Ensure body ALWAYS starts with 'e' followed by hex characters so final |
| 2151 |
|
# MSGID looks like: PREFIX + "e" + <lowercase-hex> and total length 35. |
| 2152 |
:-( |
suffix_len = max(body_len - 1, 0) |
| 2153 |
|
|
| 2154 |
:-( |
suffix = |
| 2155 |
|
if String.length(cleaned) >= suffix_len do |
| 2156 |
|
# take rightmost chars to preserve entropy |
| 2157 |
:-( |
String.slice(cleaned, -suffix_len, suffix_len) |
| 2158 |
|
else |
| 2159 |
:-( |
needed = suffix_len - String.length(cleaned) |
| 2160 |
:-( |
rand_hex = |
| 2161 |
|
:crypto.strong_rand_bytes(div(needed + 1, 2)) |
| 2162 |
|
|> Base.encode16(case: :lower) |
| 2163 |
|
|> String.slice(0, needed) |
| 2164 |
|
|
| 2165 |
:-( |
(cleaned <> rand_hex) |> String.slice(0, suffix_len) |
| 2166 |
|
end |
| 2167 |
|
|
| 2168 |
:-( |
body = suffix |
| 2169 |
:-( |
(prefix_clean <> body) |> String.slice(0, total) |
| 2170 |
|
end |
| 2171 |
|
end |
| 2172 |
|
|
| 2173 |
|
# When nil is provided, generate a proper MSGID instead of returning zeros. |
| 2174 |
:-( |
defp generate_fixed_length_msg_id(nil), do: generate_msg_id_like_sample() |
| 2175 |
|
|
| 2176 |
|
# Handle non-binary inputs by converting to string and delegating to the binary clause. |
| 2177 |
:-( |
defp generate_fixed_length_msg_id(other), do: generate_fixed_length_msg_id(to_string(other)) |
| 2178 |
|
|
| 2179 |
|
# Helper function to generate message ID like NPCI sample format |
| 2180 |
|
defp generate_msg_id_like_sample do |
| 2181 |
|
# Generate message ID in the exact expected format: ORG_PREFIX (3 chars, uppercase) |
| 2182 |
|
# followed by a literal 'e' and lowercase hex characters to reach 35 chars total. |
| 2183 |
:-( |
org_prefix = |
| 2184 |
|
Application.get_env(:da_product_app, :psp_org_prefix, "MER") |
| 2185 |
:-( |
|> to_string() |
| 2186 |
|
|> String.replace(~r/[^A-Za-z0-9]/, "") |
| 2187 |
|
|> String.slice(0, 3) |
| 2188 |
|
|> String.upcase() |
| 2189 |
|
|
| 2190 |
:-( |
total = 35 |
| 2191 |
:-( |
body_len = total - String.length(org_prefix) |
| 2192 |
|
# reserve first char of body for 'e' |
| 2193 |
:-( |
if body_len <= 0 do |
| 2194 |
:-( |
org_prefix |> String.slice(0, total) |
| 2195 |
|
else |
| 2196 |
:-( |
hex_len = max(body_len - 1, 0) |
| 2197 |
:-( |
hex = |
| 2198 |
|
:crypto.strong_rand_bytes(div(hex_len + 1, 2)) |
| 2199 |
|
|> Base.encode16(case: :lower) |
| 2200 |
|
|> String.slice(0, hex_len) |
| 2201 |
|
|
| 2202 |
:-( |
body = "e" <> hex |
| 2203 |
:-( |
(org_prefix <> body) |> String.slice(0, total) |
| 2204 |
|
end |
| 2205 |
|
end |
| 2206 |
|
|
| 2207 |
|
# T03: Format TXN.NOTE - must be alphanumeric, length 1-50 |
| 2208 |
|
defp format_txn_note(nil), do: "MT_VALQRDEP" |
| 2209 |
|
defp format_txn_note(""), do: "MT_VALQRDEP" |
| 2210 |
|
defp format_txn_note(note) when is_binary(note) do |
| 2211 |
|
# Remove non-alphanumeric characters and ensure length 1-50 |
| 2212 |
|
cleaned_note = note |
| 2213 |
|
|> String.replace(~r/[^a-zA-Z0-9_]/, "") |
| 2214 |
|
|> String.slice(0, 50) |
| 2215 |
|
|
| 2216 |
|
if String.length(cleaned_note) > 0 do |
| 2217 |
|
cleaned_note |
| 2218 |
|
else |
| 2219 |
|
"MT_VALQRDEP" |
| 2220 |
|
end |
| 2221 |
|
end |
| 2222 |
|
defp format_txn_note(_), do: "MT_VALQRDEP" |
| 2223 |
|
|
| 2224 |
|
# T12: Format TXN.CUSTREF - must be present, length 12 |
| 2225 |
:-( |
defp format_cust_ref(nil), do: generate_12_char_custref() |
| 2226 |
:-( |
defp format_cust_ref(""), do: generate_12_char_custref() |
| 2227 |
|
defp format_cust_ref(cust_ref) when is_binary(cust_ref) do |
| 2228 |
|
# Ensure exactly 12 characters |
| 2229 |
|
cust_ref |
| 2230 |
|
|> String.pad_trailing(12, "0") |
| 2231 |
:-( |
|> String.slice(0, 12) |
| 2232 |
|
end |
| 2233 |
:-( |
defp format_cust_ref(_), do: generate_12_char_custref() |
| 2234 |
|
|
| 2235 |
|
# T14: Format TXN.PURPOSE - must be present/valid |
| 2236 |
:-( |
defp format_purpose(nil), do: "11" |
| 2237 |
:-( |
defp format_purpose(""), do: "11" |
| 2238 |
|
defp format_purpose(purpose) when is_binary(purpose) do |
| 2239 |
|
# Validate against NPCI purpose codes |
| 2240 |
:-( |
valid_purposes = ["00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15"] |
| 2241 |
:-( |
if purpose in valid_purposes do |
| 2242 |
:-( |
purpose |
| 2243 |
|
else |
| 2244 |
|
"11" # Default to 11 (person to merchant) |
| 2245 |
|
end |
| 2246 |
|
end |
| 2247 |
:-( |
defp format_purpose(_), do: "11" |
| 2248 |
|
|
| 2249 |
|
# T03: Validate and preserve TXN.NOTE - must be alphanumeric, length 1-50 |
| 2250 |
|
# CRITICAL: Preserve original note value as-is to avoid NPCI T03 validation errors |
| 2251 |
:-( |
defp validate_and_preserve_note(nil), do: "PAYMENT" |
| 2252 |
:-( |
defp validate_and_preserve_note(""), do: "PAYMENT" |
| 2253 |
|
defp validate_and_preserve_note(note) when is_binary(note) do |
| 2254 |
|
# Validate that note meets NPCI T03 requirements: |
| 2255 |
|
# 1. Must be alphanumeric (including underscores which are common in UPI notes) |
| 2256 |
|
# 2. Length between 1-50 characters |
| 2257 |
|
# 3. Preserve original value exactly as received from ReqPay |
| 2258 |
|
|
| 2259 |
:-( |
case String.match?(note, ~r/^[a-zA-Z0-9_]+$/) && String.length(note) >= 1 && String.length(note) <= 50 do |
| 2260 |
:-( |
true -> note # Use original note exactly as received |
| 2261 |
:-( |
false -> "PAYMENT" # Fallback if validation fails |
| 2262 |
|
end |
| 2263 |
|
end |
| 2264 |
:-( |
defp validate_and_preserve_note(_), do: "PAYMENT" |
| 2265 |
|
|
| 2266 |
|
defp generate_12_char_custref do |
| 2267 |
|
# Generate a 12-character customer reference |
| 2268 |
:-( |
timestamp = System.system_time(:millisecond) |> Integer.to_string() |
| 2269 |
:-( |
random = :crypto.strong_rand_bytes(4) |> Base.encode16() |> String.slice(0, 8) |
| 2270 |
|
(timestamp <> random) |
| 2271 |
|
|> String.pad_trailing(12, "0") |
| 2272 |
:-( |
|> String.slice(0, 12) |
| 2273 |
|
end |
| 2274 |
|
|
| 2275 |
|
# Helper functions for generating default QR values when not provided |
| 2276 |
|
defp generate_default_expire_ts do |
| 2277 |
|
# Generate expiry timestamp 24 hours from now in IST |
| 2278 |
|
Timex.now("Asia/Kolkata") |
| 2279 |
|
|> Timex.shift(hours: 24) |
| 2280 |
|
|> Timex.format!("{YYYY}-{0M}-{0D}T{h24}:{m}:{s}+05:30") |
| 2281 |
|
end |
| 2282 |
|
|
| 2283 |
|
defp generate_default_stan do |
| 2284 |
|
# Generate 6-digit STAN (System Trace Audit Number) |
| 2285 |
|
:crypto.strong_rand_bytes(3) |
| 2286 |
|
|> :binary.bin_to_list() |
| 2287 |
|
|> Enum.map(&Integer.to_string(&1, 16)) |
| 2288 |
|
|> Enum.join() |
| 2289 |
|
|> String.pad_leading(6, "0") |
| 2290 |
|
end |
| 2291 |
|
|
| 2292 |
|
defp generate_default_qr_ts do |
| 2293 |
|
# Generate QR timestamp in IST format |
| 2294 |
|
Timex.now("Asia/Kolkata") |
| 2295 |
|
|> Timex.format!("{YYYY}-{0M}-{0D}T{h24}:{m}:{s}+05:30") |
| 2296 |
|
end |
| 2297 |
|
|
| 2298 |
:-( |
defp generate_default_risk_score do |
| 2299 |
|
"00030" |
| 2300 |
|
end |
| 2301 |
|
defp generate_default_qr_expire_ts do |
| 2302 |
|
# Generate QR expiry timestamp (24 hours from now) in IST format |
| 2303 |
|
Timex.now("Asia/Kolkata") |
| 2304 |
|
|> Timex.add(Timex.Duration.from_hours(24)) |
| 2305 |
:-( |
|> Timex.format!("{YYYY}-{0M}-{0D}T{h24}:{m}:{s}+05:30") |
| 2306 |
|
end |
| 2307 |
|
|
| 2308 |
:-( |
defp generate_default_qr_ver do |
| 2309 |
|
"02" |
| 2310 |
|
end |
| 2311 |
|
|
| 2312 |
:-( |
defp generate_default_qr_medium do |
| 2313 |
|
"03" |
| 2314 |
|
end |
| 2315 |
|
|
| 2316 |
:-( |
defp generate_default_qr_query do |
| 2317 |
|
"" |
| 2318 |
|
end |
| 2319 |
|
|
| 2320 |
:-( |
defp generate_default_qr_ver_token do |
| 2321 |
|
"" |
| 2322 |
|
end |
| 2323 |
|
|
| 2324 |
|
# Ensure message/transaction ids conform to NPCI constraints: non-empty, trimmed, max length 35 |
| 2325 |
|
defp sanitize_msg_id(id) when is_binary(id) do |
| 2326 |
|
id |
| 2327 |
|
|> String.trim() |
| 2328 |
|
|> (fn s -> if s == "", do: generate_fallback_txn_id(), else: s end).() |
| 2329 |
|
|> String.slice(0, 35) |
| 2330 |
|
end |
| 2331 |
|
|
| 2332 |
|
defp sanitize_msg_id(_), do: generate_fallback_txn_id() |
| 2333 |
|
|
| 2334 |
|
# Helper functions for STAN extraction and generation to fix NPCI mismatches |
| 2335 |
|
|
| 2336 |
|
@doc """ |
| 2337 |
|
Extract STAN from QR payload if available. |
| 2338 |
|
Looks for STAN parameter in upiGlobal URL or generates from QR data. |
| 2339 |
|
""" |
| 2340 |
|
defp extract_stan_from_qr_payload(nil), do: nil |
| 2341 |
|
defp extract_stan_from_qr_payload(""), do: nil |
| 2342 |
|
defp extract_stan_from_qr_payload(qr_payload) when is_binary(qr_payload) do |
| 2343 |
|
try do |
| 2344 |
|
# Check if QR payload contains STAN parameter |
| 2345 |
|
if String.contains?(qr_payload, "stan=") do |
| 2346 |
|
# Extract STAN parameter from URL |
| 2347 |
|
case Regex.run(~r/stan=([^&]+)/, qr_payload) do |
| 2348 |
|
[_, stan] -> |
| 2349 |
|
# Validate STAN format (6 digits) |
| 2350 |
|
if String.match?(stan, ~r/^\d{6}$/) do |
| 2351 |
|
stan |
| 2352 |
|
else |
| 2353 |
|
nil |
| 2354 |
|
end |
| 2355 |
|
_ -> nil |
| 2356 |
|
end |
| 2357 |
|
else |
| 2358 |
|
# Try to extract STAN from other QR data patterns |
| 2359 |
|
extract_stan_from_qr_components(qr_payload) |
| 2360 |
|
end |
| 2361 |
|
rescue |
| 2362 |
|
_ -> nil |
| 2363 |
|
end |
| 2364 |
|
end |
| 2365 |
|
|
| 2366 |
|
@doc """ |
| 2367 |
|
Extract STAN from QR components like tr (transaction reference) or other fields. |
| 2368 |
|
""" |
| 2369 |
|
defp extract_stan_from_qr_components(qr_payload) do |
| 2370 |
|
try do |
| 2371 |
|
# Extract transaction reference which might contain STAN |
| 2372 |
|
case Regex.run(~r/tr=([^&]+)/, qr_payload) do |
| 2373 |
|
[_, tr] when byte_size(tr) >= 6 -> |
| 2374 |
|
# Take last 6 digits if available |
| 2375 |
|
tr |
| 2376 |
|
|> String.replace(~r/[^0-9]/, "") |
| 2377 |
|
|> String.slice(-6, 6) |
| 2378 |
|
|> case do |
| 2379 |
|
stan when byte_size(stan) == 6 -> stan |
| 2380 |
|
_ -> nil |
| 2381 |
|
end |
| 2382 |
|
_ -> nil |
| 2383 |
|
end |
| 2384 |
|
rescue |
| 2385 |
|
_ -> nil |
| 2386 |
|
end |
| 2387 |
|
end |
| 2388 |
|
|
| 2389 |
|
@doc """ |
| 2390 |
|
Generate deterministic STAN based on transaction ID to ensure consistency. |
| 2391 |
|
This ensures the same transaction always gets the same STAN. |
| 2392 |
|
""" |
| 2393 |
|
defp generate_deterministic_stan(nil), do: generate_default_stan() |
| 2394 |
|
defp generate_deterministic_stan(""), do: generate_default_stan() |
| 2395 |
|
defp generate_deterministic_stan(txn_id) when is_binary(txn_id) do |
| 2396 |
|
# Generate deterministic 6-digit STAN from transaction ID using hash |
| 2397 |
|
hash = :crypto.hash(:sha256, txn_id) |
| 2398 |
|
<<num::32, _rest::binary>> = hash |
| 2399 |
|
|
| 2400 |
|
# Convert to 6-digit string, ensuring it's always 6 digits |
| 2401 |
|
num |
| 2402 |
|
|> rem(1_000_000) |
| 2403 |
|
|> Integer.to_string() |
| 2404 |
|
|> String.pad_leading(6, "0") |
| 2405 |
|
end |
| 2406 |
|
|
| 2407 |
|
defp generate_fallback_txn_id do |
| 2408 |
|
# Fallback: use timestamp + random suffix, capped to 35 chars |
| 2409 |
|
ts = Timex.now() |> Timex.format!("%Y%m%d%H%M%S", :strftime) |
| 2410 |
|
rand = :crypto.strong_rand_bytes(4) |> Base.url_encode64(padding: false) |> binary_part(0, 6) |
| 2411 |
|
(ts <> rand) |> String.slice(0, 35) |
| 2412 |
|
end |
| 2413 |
|
|
| 2414 |
|
@doc """ |
| 2415 |
|
Extract QRts timestamp from QR payload |
| 2416 |
|
CRITICAL: For NPCI TXN.QR.TS MISMATCH compliance, the QR timestamp in RespPay must match |
| 2417 |
|
the QRts value from the original QR payload, not the transaction timestamp. |
| 2418 |
|
""" |
| 2419 |
|
@doc """ |
| 2420 |
|
Public helper: Extract QRts timestamp string from QR payload |
| 2421 |
|
Returns the decoded QRts string or nil if not present. |
| 2422 |
|
""" |
| 2423 |
|
def extract_qr_ts_from_payload(request_source) do |
| 2424 |
|
require Logger |
| 2425 |
:-( |
qr_payload = Map.get(request_source, :qr_payload) |
| 2426 |
|
|
| 2427 |
:-( |
if qr_payload && qr_payload != "" do |
| 2428 |
|
# Extract QRts value from QR payload parameters |
| 2429 |
:-( |
case Regex.run(~r/QRts=([^&]+)/, qr_payload) do |
| 2430 |
|
[_, qr_ts_value] -> |
| 2431 |
|
# URL decode the timestamp |
| 2432 |
:-( |
decoded_ts = URI.decode(qr_ts_value) |
| 2433 |
:-( |
Logger.debug("Extracted QRts from payload: #{decoded_ts}") |
| 2434 |
:-( |
decoded_ts |
| 2435 |
|
nil -> |
| 2436 |
:-( |
Logger.debug("QRts not found in QR payload") |
| 2437 |
|
nil |
| 2438 |
|
end |
| 2439 |
|
else |
| 2440 |
:-( |
Logger.debug("No QR payload provided") |
| 2441 |
|
nil |
| 2442 |
|
end |
| 2443 |
|
end |
| 2444 |
|
|
| 2445 |
|
@doc """ |
| 2446 |
|
Generate approval number for transactions |
| 2447 |
|
Returns alphanumeric approval number as required by NPCI |
| 2448 |
|
""" |
| 2449 |
|
defp generate_approval_num do |
| 2450 |
:-( |
"APP" <> (:crypto.strong_rand_bytes(8) |> Base.encode16()) |
| 2451 |
|
end |
| 2452 |
|
end |