cover/Elixir.DaProductApp.QRValidation.Parser.UpiXmlParser.html

1 defmodule DaProductApp.QRValidation.Parser.UpiXmlParser do
2 @moduledoc """
3 Complete UPI XML parser supporting full specification compliance.
4 Handles ReqValQr, RespValQr, ReqPay, RespPay, ReqChkTxn, RespChkTxn, ReqReversal, RespReversal.
5 """
6 import SweetXml
7 alias Decimal, as: D
8
9 @type parsed_result :: {:ok, map()} | {:error, term()}
10
11 # UPI message type detection
12 @message_types %{
13 "ReqValQr" => :req_val_qr,
14 "RespValQr" => :resp_val_qr,
15 "ReqPay" => :req_pay,
16 "RespPay" => :resp_pay,
17 "ReqChkTxn" => :req_chk_txn,
18 "RespChkTxn" => :resp_chk_txn,
19 "ReqReversal" => :req_reversal,
20 "RespReversal" => :resp_reversal
21 }
22
23 # Complete field mappings per UPI specification
24 @header_fields %{
25 "msgId" => :msg_id,
26 "ts" => :timestamp,
27 "orgId" => :org_id,
28 "channelId" => :channel_id,
29 "version" => :version,
30 "sign" => :signature
31 }
32
33 @qr_fields %{
34 "addr" => :payee_addr,
35 "mid" => :payee_mid,
36 "tid" => :terminal_id,
37 "mc" => :merchant_code,
38 "purpose" => :purpose_code,
39 "am" => :amount,
40 "cu" => :currency,
41 "tn" => :transaction_note,
42 "url" => :url,
43 "mode" => :mode,
44 "orgId" => :org_id,
45 "sign" => :signature,
46 "ts" => :timestamp
47 }
48
49 @payer_fields %{
50 "addr" => :addr,
51 "name" => :name,
52 "seqNum" => :seq_num,
53 "type" => :type,
54 "code" => :code,
55 "ac" => :account_number,
56 "ifa" => :ifa
57 }
58
59 @payee_fields %{
60 "addr" => :addr,
61 "name" => :name,
62 "seqNum" => :seq_num,
63 "type" => :type,
64 "code" => :code,
65 "ac" => :account_number,
66 "ifa" => :ifa,
67 "mc" => :merchant_code,
68 "tid" => :terminal_id,
69 "mid" => :merchant_id
70 }
71
72 @txn_fields %{
73 "id" => :id,
74 "note" => :note,
75 "refId" => :ref_id,
76 "refUrl" => :ref_url,
77 "ts" => :timestamp,
78 "type" => :type,
79 "amount" => :amount,
80 "currency" => :currency,
81 "custRef" => :customer_ref
82 }
83
84 @fx_fields %{
85 "baseCurr" => :base_currency,
86 "baseAmount" => :base_amount,
87 "Fx" => :fx_rate,
88 "Mkup" => :markup_pct,
89 "active" => :active,
90 "lastModifedTs" => :last_modified_ts,
91 "validUpto" => :valid_until
92 }
93
94 @spec parse_upi_xml(String.t()) :: parsed_result()
95 def parse_upi_xml(xml) when is_binary(xml) do
96 11 try do
97 11 doc = SweetXml.parse(xml)
98 10 message_type = detect_message_type(doc, xml)
99
100 10 case message_type do
101 6 :req_val_qr -> parse_req_val_qr(doc, xml)
102 2 :resp_val_qr -> parse_resp_val_qr(doc, xml)
103 1 :req_pay -> parse_req_pay(doc, xml)
104
:-(
:resp_pay -> parse_resp_pay(doc, xml)
105
:-(
:req_chk_txn -> parse_req_chk_txn(doc, xml)
106
:-(
:resp_chk_txn -> parse_resp_chk_txn(doc, xml)
107
:-(
:req_reversal -> parse_req_reversal(doc, xml)
108
:-(
:resp_reversal -> parse_resp_reversal(doc, xml)
109 1 nil -> {:error, {:unknown_message_type, extract_root_element_name(xml)}}
110 end
111 rescue
112
:-(
e -> {:error, {:parse_error, Exception.message(e)}}
113 catch
114 1 :exit, reason -> {:error, {:parse_error, "XML parsing failed: #{inspect(reason)}"}}
115 end
116 end
117
118 # --- Message Type Detection ---
119 defp detect_message_type(doc, xml) do
120 10 try do
121 10 root_element = xpath(doc, ~x"/*"e)
122 10 case root_element do
123 {element_name, _, _} ->
124
:-(
element_str = to_string(element_name)
125
:-(
Map.get(@message_types, element_str)
126 element_name when is_atom(element_name) ->
127
:-(
element_str = to_string(element_name)
128
:-(
Map.get(@message_types, element_str)
129 _ ->
130 # Fallback: try to extract from XML string directly
131 10 extract_message_type_from_xml(xml)
132 end
133 rescue
134
:-(
_ -> extract_message_type_from_xml(xml)
135 end
136 end
137
138 defp extract_message_type_from_xml(xml) do
139 10 cond do
140 6 String.contains?(xml, "<ReqValQr") -> :req_val_qr
141 4 String.contains?(xml, "<RespValQr") -> :resp_val_qr
142 2 String.contains?(xml, "<ReqPay") -> :req_pay
143 1 String.contains?(xml, "<RespPay") -> :resp_pay
144 1 String.contains?(xml, "<ReqChkTxn") -> :req_chk_txn
145 1 String.contains?(xml, "<RespChkTxn") -> :resp_chk_txn
146 1 String.contains?(xml, "<ReqReversal") -> :req_reversal
147 1 String.contains?(xml, "<RespReversal") -> :resp_reversal
148 1 true -> nil
149 end
150 end
151
152 defp extract_root_element_name(xml) do
153 1 case Regex.run(~r/<(\w+)/, xml) do
154 1 [_, element_name] -> element_name
155
:-(
_ -> "unknown"
156 end
157 end
158
159 # --- ReqValQr Parser ---
160 6 defp parse_req_val_qr(doc, raw_xml) do
161 {:ok, %{
162 message_type: :req_val_qr,
163 header: parse_header(doc),
164 qr: parse_qr_section(doc),
165 raw_xml: raw_xml,
166 parsed_at: DateTime.utc_now()
167 }}
168 end
169
170 # --- RespValQr Parser ---
171 2 defp parse_resp_val_qr(doc, raw_xml) do
172 {:ok, %{
173 message_type: :resp_val_qr,
174 header: parse_header(doc),
175 resp: parse_resp_section(doc),
176 fx_list: parse_fx_list(doc),
177 raw_xml: raw_xml,
178 parsed_at: DateTime.utc_now()
179 }}
180 end
181
182 # --- ReqPay Parser ---
183 1 defp parse_req_pay(doc, raw_xml) do
184 {:ok, %{
185 message_type: :req_pay,
186 header: parse_header(doc),
187 payer: parse_payer_section(doc),
188 payee: parse_payee_section(doc),
189 txn: parse_txn_section(doc),
190 raw_xml: raw_xml,
191 parsed_at: DateTime.utc_now()
192 }}
193 end
194
195 # --- RespPay Parser ---
196
:-(
defp parse_resp_pay(doc, raw_xml) do
197 {:ok, %{
198 message_type: :resp_pay,
199 header: parse_header(doc),
200 resp: parse_resp_section(doc),
201 txn: parse_txn_section(doc),
202 raw_xml: raw_xml,
203 parsed_at: DateTime.utc_now()
204 }}
205 end
206
207 # --- ReqChkTxn Parser ---
208
:-(
defp parse_req_chk_txn(doc, raw_xml) do
209 {:ok, %{
210 message_type: :req_chk_txn,
211 header: parse_header(doc),
212 txn: parse_txn_section(doc),
213 raw_xml: raw_xml,
214 parsed_at: DateTime.utc_now()
215 }}
216 end
217
218 # --- RespChkTxn Parser ---
219
:-(
defp parse_resp_chk_txn(doc, raw_xml) do
220 {:ok, %{
221 message_type: :resp_chk_txn,
222 header: parse_header(doc),
223 resp: parse_resp_section(doc),
224 txn: parse_txn_section(doc),
225 raw_xml: raw_xml,
226 parsed_at: DateTime.utc_now()
227 }}
228 end
229
230 # --- ReqReversal Parser ---
231
:-(
defp parse_req_reversal(doc, raw_xml) do
232 {:ok, %{
233 message_type: :req_reversal,
234 header: parse_header(doc),
235 txn: parse_txn_section(doc),
236 raw_xml: raw_xml,
237 parsed_at: DateTime.utc_now()
238 }}
239 end
240
241 # --- RespReversal Parser ---
242
:-(
defp parse_resp_reversal(doc, raw_xml) do
243 {:ok, %{
244 message_type: :resp_reversal,
245 header: parse_header(doc),
246 resp: parse_resp_section(doc),
247 txn: parse_txn_section(doc),
248 raw_xml: raw_xml,
249 parsed_at: DateTime.utc_now()
250 }}
251 end
252
253 # --- Section Parsers ---
254 defp parse_header(doc) do
255 extract_attributes(doc, "//Head", @header_fields)
256 9 |> normalize_timestamp(:timestamp)
257 end
258
259 defp parse_qr_section(doc) do
260 extract_attributes(doc, "//Qr", @qr_fields)
261 |> normalize_decimal(:amount)
262 6 |> normalize_timestamp(:timestamp)
263 end
264
265 defp parse_payer_section(doc) do
266 1 base = extract_attributes(doc, "//Payer", @payer_fields)
267
268 # Handle nested Ac (Account) element if present
269 1 account = case xpath(doc, ~x"//Payer/Ac") do
270
:-(
nil -> nil
271 _node ->
272 1 %{
273 account_number: xpath(doc, ~x"//Payer/Ac/@ac"s),
274 ifa: xpath(doc, ~x"//Payer/Ac/@ifa"s)
275 }
276 end
277
278 1 Map.put(base, :account, account)
279 end
280
281 defp parse_payee_section(doc) do
282 1 base = extract_attributes(doc, "//Payee", @payee_fields)
283
284 # Handle nested Ac (Account) element if present
285 1 account = case xpath(doc, ~x"//Payee/Ac") do
286
:-(
nil -> nil
287 _node ->
288 1 %{
289 account_number: xpath(doc, ~x"//Payee/Ac/@ac"s),
290 ifa: xpath(doc, ~x"//Payee/Ac/@ifa"s)
291 }
292 end
293
294 1 Map.put(base, :account, account)
295 end
296
297 defp parse_txn_section(doc) do
298 extract_attributes(doc, "//Txn", @txn_fields)
299 |> normalize_decimal(:amount)
300 1 |> normalize_timestamp(:timestamp)
301 end
302
303 defp parse_resp_section(doc) do
304 %{
305 result: xpath(doc, ~x"//Resp/@result"s),
306 err_code: xpath(doc, ~x"//Resp/@errCode"s),
307 err_msg: xpath(doc, ~x"//Resp/@errMsg"s),
308 approval_num: xpath(doc, ~x"//Resp/@approvalNum"s),
309 status: xpath(doc, ~x"//Resp/@status"s),
310 ver_token: xpath(doc, ~x"//Resp/@verToken"s)
311 }
312 2 |> reject_empty_values()
313 end
314
315 defp parse_fx_list(doc) do
316 xpath(doc, ~x"//FxList/Fx"l)
317 2 |> Enum.map(fn node ->
318 extract_node_attributes(node, @fx_fields)
319 |> normalize_decimal(:base_amount)
320 |> normalize_decimal(:fx_rate)
321 |> normalize_decimal(:markup_pct)
322 |> normalize_boolean(:active)
323 |> normalize_timestamp(:last_modified_ts)
324 5 |> normalize_timestamp(:valid_until)
325 end)
326 end
327
328 # --- Helper Functions ---
329 defp extract_attributes(doc, xpath_str, field_map) do
330 18 Enum.reduce(field_map, %{}, fn {xml_attr, field_key}, acc ->
331 158 value = xpath(doc, ~x"#{xpath_str}/@#{xml_attr}"s)
332 158 if value && value != "", do: Map.put(acc, field_key, value), else: acc
333 end)
334 end
335
336 defp extract_node_attributes(node, field_map) do
337 5 Enum.reduce(field_map, %{}, fn {xml_attr, field_key}, acc ->
338 35 value = xpath(node, ~x"./@#{xml_attr}"s)
339 35 if value && value != "", do: Map.put(acc, field_key, value), else: acc
340 end)
341 end
342
343 defp normalize_decimal(map, key) do
344 22 case Map.get(map, key) do
345 13 nil -> map
346
:-(
"" -> map
347 9 value -> Map.put(map, key, to_decimal(value))
348 end
349 end
350
351 defp normalize_timestamp(map, key) do
352 26 case Map.get(map, key) do
353 13 nil -> map
354
:-(
"" -> map
355 13 value -> Map.put(map, key, parse_datetime(value))
356 end
357 end
358
359 defp normalize_boolean(map, key) do
360 5 case Map.get(map, key) do
361
:-(
nil -> map
362
:-(
"" -> map
363 2 "true" -> Map.put(map, key, true)
364 2 "false" -> Map.put(map, key, false)
365 1 "1" -> Map.put(map, key, true)
366
:-(
"0" -> Map.put(map, key, false)
367
:-(
_ -> map
368 end
369 end
370
371 defp reject_empty_values(map) do
372 map
373 12 |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
374 2 |> Enum.into(%{})
375 end
376
377 # --- Data Type Converters ---
378
:-(
defp to_decimal(nil), do: nil
379
:-(
defp to_decimal(""), do: nil
380 defp to_decimal(value) when is_binary(value) do
381 9 case D.parse(value) do
382 9 {decimal, ""} -> decimal # Successfully parsed entire string
383
:-(
{_decimal, remainder} when remainder != "" -> nil # Invalid: extra characters present
384
:-(
:error -> nil
385 end
386 end
387
:-(
defp to_decimal(value) when is_number(value), do: D.new(value)
388
:-(
defp to_decimal(_), do: nil
389
390
:-(
defp parse_datetime(nil), do: nil
391
:-(
defp parse_datetime(""), do: nil
392 defp parse_datetime(timestamp_str) when is_binary(timestamp_str) do
393 13 case DateTime.from_iso8601(timestamp_str) do
394 13 {:ok, dt, _offset} -> dt
395 {:error, _} ->
396 # Try alternative formats
397
:-(
case NaiveDateTime.from_iso8601(timestamp_str) do
398
:-(
{:ok, naive_dt} -> DateTime.from_naive!(naive_dt, "Etc/UTC")
399
:-(
{:error, _} -> nil
400 end
401 end
402 end
403
:-(
defp parse_datetime(_), do: nil
404
405 # --- Validation Functions ---
406 @spec validate_required_fields(map(), atom(), [atom()]) :: {:ok, map()} | {:error, term()}
407 def validate_required_fields(parsed_data, message_type, required_fields) do
408 2 missing_fields =
409 required_fields
410 |> Enum.reject(fn field ->
411 8 get_in(parsed_data, field_path(field)) != nil
412 end)
413
414 2 case missing_fields do
415 1 [] -> {:ok, parsed_data}
416 1 missing -> {:error, {:missing_required_fields, message_type, missing}}
417 end
418 end
419
420 defp field_path(field) when is_atom(field) do
421 8 case field do
422 2 :msg_id -> [:header, :msg_id]
423 2 :org_id -> [:header, :org_id]
424 2 :timestamp -> [:header, :timestamp]
425 2 :payee_addr -> [:qr, :payee_addr]
426
:-(
:payee_mid -> [:qr, :payee_mid]
427
:-(
_ -> [field]
428 end
429 end
430
431 # Required fields per message type
432 @required_fields %{
433 req_val_qr: [:msg_id, :org_id, :timestamp, :payee_addr],
434 resp_val_qr: [:msg_id, :org_id, :timestamp],
435 req_pay: [:msg_id, :org_id, :timestamp, :payee_addr, :payer_addr],
436 resp_pay: [:msg_id, :org_id, :timestamp],
437 req_chk_txn: [:msg_id, :org_id, :timestamp],
438 resp_chk_txn: [:msg_id, :org_id, :timestamp],
439 req_reversal: [:msg_id, :org_id, :timestamp],
440 resp_reversal: [:msg_id, :org_id, :timestamp]
441 }
442
443 @spec parse_and_validate(String.t()) :: parsed_result()
444 def parse_and_validate(xml) do
445 2 with {:ok, parsed} <- parse_upi_xml(xml),
446 2 required_fields <- Map.get(@required_fields, parsed.message_type, []),
447 2 {:ok, validated} <- validate_required_fields(parsed, parsed.message_type, required_fields) do
448 {:ok, validated}
449 end
450 end
451 end
Line Hits Source