cover/Elixir.DaProductAppWeb.UpiXmlSchema.html

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#]+;)/, "&amp;")
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#]+;)/, "&amp;")
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("&", "&amp;")
2112 |> String.replace("<", "&lt;")
2113 |> String.replace(">", "&gt;")
2114 |> String.replace("\"", "&quot;")
2115
:-(
|> String.replace("'", "&apos;")
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
Line Hits Source