cover/Elixir.DaProductApp.QRValidation.Service.html

1 defmodule DaProductApp.QRValidation.Service do
2 @moduledoc """
3 Service layer for handling QR validation normalization & persistence.
4 """
5 import Ecto.Query
6 alias Ecto.Multi
7 alias DaProductApp.Repo
8 alias DaProductApp.QRValidation.{QRValidation, QRValidationEvent, FXQuote}
9 alias DaProductApp.Merchants.{Merchant, MerchantInvoice}
10 alias DaProductApp.QRValidation.Parser.ValQrParser
11 alias DaProductApp.Transactions.{Transaction, TransactionEvent}
12 require Logger
13
14 @spec get_validation_with_quotes(integer() | String.t()) :: QRValidation.t() | nil
15 def get_validation_with_quotes(id) do
16 QRValidation
17 |> where([q], q.id == ^id)
18
:-(
|> preload([:fx_quotes, :events])
19
:-(
|> Repo.one()
20 end
21
22 @spec create_validation(map()) :: {:ok, QRValidation.t()} | {:error, any()}
23 def create_validation(attrs) do
24 Multi.new()
25 |> Multi.run(:qr_validation, fn _repo, _changes ->
26 5 upsert_qr_validation(attrs)
27 end)
28 |> Multi.run(:fx_quotes, fn _repo, %{qr_validation: v} ->
29
:-(
quotes = Map.get(attrs, :fx_quotes, [])
30
:-(
insert_fx_quotes(v, quotes)
31 end)
32 |> Multi.run(:merchant, fn _repo, %{qr_validation: _v} ->
33
:-(
merch_attrs = Map.get(attrs, :merchant)
34
:-(
upsert_merchant(merch_attrs)
35 end)
36 |> Multi.run(:invoice, fn _repo, %{merchant: m, qr_validation: v} ->
37
:-(
invoice_attrs = Map.get(attrs, :invoice)
38
:-(
maybe_insert_invoice(m, v, invoice_attrs)
39 end)
40 |> Multi.run(:event, fn _repo, %{qr_validation: v} ->
41
:-(
append_event_with_transaction_link(v.id, :valqr_received, %{status: v.status})
42 end)
43 |> Repo.transaction()
44 5 |> case do
45
:-(
{:ok, %{qr_validation: v}} -> {:ok, v}
46 5 {:error, step, reason, _} -> {:error, {step, reason}}
47 end
48 end
49
50 @spec update_validation(QRValidation.t(), map()) :: {:ok, QRValidation.t()} | {:error, Ecto.Changeset.t()}
51 def update_validation(%QRValidation{} = qr_validation, attrs) do
52 qr_validation
53 |> QRValidation.changeset(attrs)
54
:-(
|> Repo.update()
55 end
56
57 defp upsert_qr_validation(attrs) do
58 5 case Repo.get_by(QRValidation, msg_id: attrs.msg_id, org_id: attrs.org_id) do
59 nil ->
60 5 %QRValidation{} |> QRValidation.changeset(attrs) |> Repo.insert()
61 validation ->
62
:-(
validation |> QRValidation.changeset(attrs) |> Repo.update()
63 end
64 end
65
66
:-(
defp insert_fx_quotes(_v, []), do: {:ok, []}
67 defp insert_fx_quotes(v, quotes) do
68
:-(
entries =
69 Enum.map(quotes, fn q ->
70
:-(
FXQuote.changeset(%FXQuote{}, Map.merge(q, %{qr_validation_id: v.id, inserted_at: DateTime.utc_now()}))
71 end)
72
73
:-(
case Repo.insert_all(FXQuote, Enum.map(entries, & &1.changes), returning: true) do
74
:-(
{_, rows} -> {:ok, rows}
75 end
76 end
77
78
:-(
defp upsert_merchant(nil), do: {:ok, nil}
79 defp upsert_merchant(attrs) do
80
:-(
case Repo.get_by(Merchant, mid: attrs.mid, tid: attrs.tid) do
81 nil ->
82
:-(
%Merchant{} |> Merchant.changeset(Map.from_struct(attrs)) |> Repo.insert()
83 merchant ->
84
:-(
merchant |> Merchant.changeset(Map.from_struct(attrs)) |> Repo.update()
85 end
86 end
87
88
:-(
defp maybe_insert_invoice(_m, _v, nil), do: {:ok, nil}
89
:-(
defp maybe_insert_invoice(nil, _v, _attrs), do: {:ok, nil}
90 defp maybe_insert_invoice(m, v, attrs) do
91 %MerchantInvoice{}
92
:-(
|> MerchantInvoice.changeset(Map.merge(attrs, %{merchant_id: m.id, qr_validation_id: v.id}))
93
:-(
|> Repo.insert()
94 end
95
96 @spec append_event(binary(), atom(), map()) :: {:ok, QRValidationEvent.t()} | {:error, any()}
97 def append_event(qr_validation_id, event_type, payload) do
98
:-(
last =
99 QRValidationEvent
100 |> where([e], e.qr_validation_id == ^qr_validation_id)
101 |> order_by([e], desc: e.seq)
102
:-(
|> limit(1)
103 |> Repo.one()
104
105
:-(
seq = if last, do: last.seq + 1, else: 1
106
:-(
prev_hash = last && last.hash
107
:-(
now = DateTime.utc_now()
108
:-(
hash = hash_event(prev_hash, seq, event_type, payload, now)
109
110 %QRValidationEvent{}
111 |> QRValidationEvent.changeset(%{
112 qr_validation_id: qr_validation_id,
113 seq: seq,
114
:-(
event_type: to_string(event_type),
115 prev_hash: prev_hash,
116 hash: hash,
117 payload: payload,
118 inserted_at: now
119 })
120
:-(
|> Repo.insert()
121 end
122
123 defp hash_event(prev_hash, seq, event_type, payload, ts) do
124
:-(
base = :erlang.term_to_binary({prev_hash, seq, event_type, payload, ts})
125
:-(
:crypto.hash(:sha256, base)
126 end
127
128 @doc """
129 Enhanced event logging that logs to both qr_validation_events and transaction_events
130 when a transaction is linked to the QR validation.
131 """
132 @spec append_event_with_transaction_link(binary(), atom(), map()) :: {:ok, QRValidationEvent.t()} | {:error, any()}
133 def append_event_with_transaction_link(qr_validation_id, event_type, payload) do
134 Multi.new()
135 |> Multi.run(:qr_event, fn _repo, _changes ->
136
:-(
append_event(qr_validation_id, event_type, payload)
137 end)
138 |> Multi.run(:transaction_event, fn _repo, %{qr_event: _qr_event} ->
139
:-(
maybe_append_transaction_event(qr_validation_id, event_type, payload)
140 end)
141 |> Repo.transaction()
142
:-(
|> case do
143
:-(
{:ok, %{qr_event: qr_event}} -> {:ok, qr_event}
144
:-(
{:error, step, reason, _} -> {:error, {step, reason}}
145 end
146 end
147
148 # Find transaction linked to QR validation by parent_qr_validation_id
149 defp find_transaction_by_qr_validation(qr_validation_id) do
150 from(t in Transaction,
151 where: t.parent_qr_validation_id == ^qr_validation_id)
152
:-(
|> Repo.one()
153 end
154
155 # Map QR validation event types to transaction event types
156 defp map_qr_event_to_transaction_event(qr_event_type) do
157
:-(
case qr_event_type do
158
:-(
:valqr_received -> :qr_validation_received
159
:-(
:valqr_validated -> :qr_validation_completed
160
:-(
:valqr_updated -> :qr_validation_updated
161
:-(
:valqr_failed -> :qr_validation_failed
162
:-(
:respvalqr_generated -> :qr_response_generated
163
:-(
_ -> :qr_validation_unknown
164 end
165 end
166
167 # Append QR validation event to transaction events if transaction exists
168 defp maybe_append_transaction_event(qr_validation_id, qr_event_type, qr_payload) do
169
:-(
case find_transaction_by_qr_validation(qr_validation_id) do
170 nil ->
171
:-(
Logger.debug("No transaction found for QR validation ID: #{qr_validation_id}")
172 {:ok, :no_transaction_linked}
173
174 transaction ->
175
:-(
transaction_event_type = map_qr_event_to_transaction_event(qr_event_type)
176
177 # Create payload with QR validation reference
178
:-(
transaction_payload = %{
179 qr_validation_id: qr_validation_id,
180 qr_validation_status: Map.get(qr_payload, :status),
181 qr_event_type: Atom.to_string(qr_event_type)
182 }
183
184
:-(
append_transaction_event(transaction.id, transaction_event_type, transaction_payload)
185 end
186 end
187
188 # Append event to transaction events with proper sequencing and hash chaining
189 defp append_transaction_event(transaction_id, event_type, payload) do
190
:-(
last =
191 TransactionEvent
192 |> where([e], e.transaction_id == ^transaction_id)
193 |> order_by([e], desc: e.seq)
194
:-(
|> limit(1)
195 |> Repo.one()
196
197
:-(
seq = if last, do: last.seq + 1, else: 1
198
:-(
prev_hash = last && last.hash
199
:-(
now = DateTime.utc_now()
200
:-(
hash = hash_event(prev_hash, seq, event_type, payload, now)
201
202 %TransactionEvent{}
203 |> TransactionEvent.changeset(%{
204 transaction_id: transaction_id,
205 seq: seq,
206
:-(
event_type: to_string(event_type),
207 prev_hash: prev_hash,
208 hash: hash,
209 payload: payload,
210 inserted_at: now
211 })
212
:-(
|> Repo.insert()
213 end
214
215 alias DaProductApp.QRValidation.Parser.UpiXmlParser
216
217 @spec ingest_valqr_xml(String.t()) :: {:ok, QRValidation.t()} | {:error, any()}
218 def ingest_valqr_xml(xml) do
219
:-(
with {:ok, parsed} <- UpiXmlParser.parse_and_validate(xml),
220
:-(
{:ok, normalized} <- normalize_parsed_data(parsed),
221
:-(
{:ok, qr_validation} <- create_validation(normalized) do
222 {:ok, qr_validation}
223 else
224
:-(
{:error, reason} -> {:error, {:parser, reason}}
225 end
226 end
227
228 defp normalize_parsed_data(%{message_type: :req_val_qr} = parsed) do
229
:-(
qr_data = parsed.qr || %{}
230
:-(
header_data = parsed.header || %{}
231
232 {:ok, %{
233
:-(
msg_id: header_data.msg_id,
234
:-(
org_id: header_data.org_id,
235
:-(
channel_id: header_data.channel_id,
236
:-(
payee_addr: qr_data.payee_addr,
237
:-(
payee_mid: qr_data.payee_mid,
238
:-(
terminal_id: qr_data.terminal_id,
239
:-(
merchant_code: qr_data.merchant_code,
240
:-(
purpose_code: qr_data.purpose_code,
241
:-(
amount: qr_data.amount,
242
:-(
currency: qr_data.currency,
243
:-(
transaction_note: qr_data.transaction_note,
244 status: "pending",
245 ver_token: generate_ver_token(),
246
:-(
raw_xml: parsed.raw_xml,
247
:-(
parsed_at: parsed.parsed_at,
248 fx_quotes: [], # Will be populated from RespValQr
249 merchant: extract_merchant_data(qr_data)
250 }}
251 end
252
253 defp normalize_parsed_data(%{message_type: :resp_val_qr} = parsed) do
254
:-(
resp_data = parsed.resp || %{}
255
:-(
fx_list = parsed.fx_list || []
256
257 {:ok, %{
258
:-(
status: map_response_status(resp_data.result),
259
:-(
ver_token: resp_data.ver_token,
260 fx_quotes: Enum.map(fx_list, &normalize_fx_quote/1)
261 }}
262 end
263
264
:-(
defp normalize_parsed_data(_parsed) do
265 {:error, :unsupported_message_type}
266 end
267
268 defp extract_merchant_data(qr_data) do
269 %{
270
:-(
mid: qr_data.payee_mid,
271
:-(
addr: qr_data.payee_addr,
272
:-(
terminal_id: qr_data.terminal_id,
273
:-(
merchant_code: qr_data.merchant_code
274 }
275
:-(
|> reject_nil_values()
276 end
277
278 defp normalize_fx_quote(fx_data) do
279 %{
280
:-(
foreign_currency: fx_data.base_currency,
281
:-(
fx_rate: fx_data.fx_rate,
282
:-(
markup_pct: fx_data.markup_pct,
283
:-(
active: fx_data.active,
284
:-(
valid_until: fx_data.valid_until,
285
:-(
last_modified_at: fx_data.last_modified_ts
286 }
287
:-(
|> reject_nil_values()
288 end
289
290
:-(
defp map_response_status("SUCCESS"), do: "valid"
291
:-(
defp map_response_status("FAILURE"), do: "invalid"
292
:-(
defp map_response_status(_), do: "pending"
293
294 defp generate_ver_token do
295
:-(
"VER-" <> (Ecto.UUID.generate() |> binary_part(0, 12))
296 end
297
298 defp reject_nil_values(map) do
299 map
300
:-(
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
301
:-(
|> Enum.into(%{})
302 end
303
304 # Legacy support - keeping old function for compatibility
305 def ingest_valqr_xml_legacy(xml) do
306
:-(
with {:ok, parsed} <- ValQrParser.parse_resp(xml) do
307
:-(
attrs = %{
308
:-(
msg_id: parsed.msg_id,
309
:-(
org_id: parsed.org_id,
310
:-(
txn_id: parsed.txn_id,
311
:-(
payer_addr: parsed.payer_addr,
312
:-(
payer_name: parsed.payer_name,
313
:-(
ver_token: parsed.ver_token,
314
:-(
raw_xml: parsed.raw_xml,
315
:-(
fx_rate: first_fx(parsed.fx_quotes, :fx_rate),
316
:-(
markup_pct: first_fx(parsed.fx_quotes, :markup_pct),
317
:-(
foreign_currency: first_fx(parsed.fx_quotes, :base_currency),
318
:-(
foreign_amount: first_fx(parsed.fx_quotes, :base_amount),
319
:-(
fx_quotes: parsed.fx_quotes
320 }
321
:-(
create_validation(attrs)
322 end
323 end
324
325 defp first_fx(list, key) do
326
:-(
case List.first(list) do
327
:-(
nil -> nil
328
:-(
m -> Map.get(m, key)
329 end
330 end
331
332 @doc """
333 Ingest a RespValQr XML and persist/update the corresponding QR validation row.
334
335 Matching logic:
336 - Extract reqMsgId from <Resp reqMsgId="..."> which maps to original `msg_id` of ReqValQr
337 - Find existing qr_validations row by msg_id (ReqValQr) and org_id
338 - Update FX quotes, ver_token (if provided), status, resp_xml and npci_response_sent_at
339
340 Returns {:ok, updated_qr_validation} | {:error, reason}
341 """
342 @spec ingest_resp_valqr_xml(String.t()) :: {:ok, QRValidation.t()} | {:error, any()}
343 def ingest_resp_valqr_xml(xml) when is_binary(xml) do
344
:-(
with {:ok, parsed} <- UpiXmlParser.parse_and_validate(xml),
345
:-(
true <- parsed.message_type == :resp_val_qr || {:error, :not_resp_val_qr},
346
:-(
req_msg_id <- extract_req_msg_id(parsed),
347
:-(
org_id <- get_in(parsed, [:header, :org_id]),
348
:-(
%QRValidation{} = existing <- find_original_qr_validation(req_msg_id, org_id),
349
:-(
update_attrs <- build_resp_valqr_update_attrs(parsed, xml),
350
:-(
{:ok, updated} <- update_qr_with_resp(existing, update_attrs) do
351 {:ok, updated}
352 else
353
:-(
{:error, _} = err -> err
354
:-(
nil -> {:error, :original_qr_not_found}
355
:-(
false -> {:error, :not_resp_val_qr}
356
:-(
other -> {:error, other}
357 end
358 end
359
360 defp extract_req_msg_id(parsed) do
361 # Try multiple possible locations for reqMsgId
362
:-(
get_in(parsed, [:resp, :req_msg_id]) ||
363
:-(
get_in(parsed, [:resp, :reqMsgId]) ||
364
:-(
get_in(parsed, [:header, :req_msg_id]) ||
365
:-(
get_in(parsed, [:header, :reqMsgId])
366 end
367
368 @doc """
369 Find QR validation record by message ID and organization ID.
370 Used for locating existing QR validations during error response generation.
371 """
372 @spec find_original_qr_validation(String.t(), String.t()) :: QRValidation.t() | nil
373 def find_original_qr_validation(req_msg_id, org_id) when is_binary(req_msg_id) and is_binary(org_id) do
374
:-(
Repo.get_by(QRValidation, msg_id: req_msg_id, org_id: org_id)
375 end
376
:-(
def find_original_qr_validation(_, _), do: nil
377
378 defp build_resp_valqr_update_attrs(parsed, raw_xml) do
379
:-(
resp = parsed.resp || %{}
380
:-(
fx_list = parsed.fx_list || []
381
382 %{
383 # Update status based on result
384
:-(
status: map_response_status(resp.result),
385
:-(
ver_token: resp.ver_token,
386 resp_xml: raw_xml,
387
:-(
npci_response_sent_at: parsed.parsed_at,
388 fx_quotes: Enum.map(fx_list, &normalize_fx_quote/1)
389 }
390
:-(
|> reject_nil_values()
391 end
392
393 defp update_qr_with_resp(%QRValidation{} = qr, attrs) do
394 Repo.transaction(fn ->
395 # First update main record (excluding fx_quotes handled separately)
396
:-(
{fx_quotes, base_attrs} = Map.pop(attrs, :fx_quotes, [])
397
:-(
{:ok, updated} = qr |> QRValidation.changeset(base_attrs) |> Repo.update()
398
399 # Replace (simple approach) existing quotes if provided
400
:-(
unless fx_quotes == [] do
401
:-(
Repo.delete_all(from q in FXQuote, where: q.qr_validation_id == ^qr.id)
402
:-(
{:ok, _} = insert_fx_quotes(updated, fx_quotes)
403 end
404
405 # Append event in qr_validation_events and transaction_events (if linked)
406
:-(
_ = append_event_with_transaction_link(updated.id, :valqr_updated, %{status: updated.status, quotes: length(fx_quotes)})
407
408
:-(
updated
409 end)
410
:-(
|> case do
411
:-(
{:ok, updated} -> {:ok, updated}
412
:-(
{:error, reason} -> {:error, reason}
413 end
414 end
415
416
:-(
defp map_response_status("SUCCESS"), do: "valid"
417
:-(
defp map_response_status("FAILURE"), do: "invalid"
418
:-(
defp map_response_status(_), do: "pending"
419
420 defp normalize_fx_quote(fx_data) do
421 %{
422
:-(
foreign_currency: fx_data.base_currency,
423
:-(
fx_rate: fx_data.fx_rate,
424
:-(
markup_pct: fx_data.markup_pct,
425
:-(
active: fx_data.active,
426
:-(
valid_until: fx_data.valid_until,
427
:-(
last_modified_at: fx_data.last_modified_ts
428 }
429
:-(
|> reject_nil_values()
430 end
431
432 @doc """
433 Generate SHA-256 hash of XML content for storage.
434 Returns the hash as binary for compact database storage.
435 """
436 @spec hash_xml_content(String.t()) :: binary()
437 def hash_xml_content(xml_string) when is_binary(xml_string) do
438
:-(
:crypto.hash(:sha256, xml_string)
439 end
440
441 @doc """
442 Store RespValQr XML hash in qr_validation record and create corresponding event.
443 This is called after generating the response XML but before sending to NPCI.
444 """
445 @spec store_response_xml_hash(QRValidation.t(), String.t()) :: {:ok, QRValidation.t()} | {:error, any()}
446 def store_response_xml_hash(%QRValidation{} = qr_validation, respvalqr_xml) when is_binary(respvalqr_xml) do
447
:-(
xml_hash = hash_xml_content(respvalqr_xml)
448
449 Multi.new()
450 |> Multi.run(:update_validation, fn _repo, _changes ->
451 qr_validation
452 |> QRValidation.changeset(%{resp_xml: xml_hash})
453
:-(
|> Repo.update()
454 end)
455 |> Multi.run(:create_event, fn _repo, %{update_validation: updated_qr} ->
456
:-(
append_event_with_transaction_link(
457
:-(
updated_qr.id,
458 :respvalqr_generated,
459 %{
460 xml_hash: Base.encode64(xml_hash),
461 xml_length: byte_size(respvalqr_xml),
462 generated_at: DateTime.utc_now(),
463 status: "xml_generated"
464 }
465 )
466 end)
467 |> Repo.transaction()
468
:-(
|> case do
469
:-(
{:ok, %{update_validation: updated_qr}} -> {:ok, updated_qr}
470
:-(
{:error, step, reason, _} -> {:error, {step, reason}}
471 end
472 end
473 end
Line Hits Source