cover/Elixir.DaProductAppWeb.Api.V1.QRValidationController.html

1
:-(
defmodule DaProductAppWeb.Api.V1.QRValidationController do
2 @moduledoc """
3 Controller for Partner-facing QR code generation and status APIs.
4 These are different from NPCI QR validation APIs in UpiController.
5
6 Partner Flow:
7 1. Partner requests QR generation for their merchant
8 2. PSP generates dynamic QR with international parameters
9 3. defp validate_amount(_), do: {:error, "must be a string"}
10
11 defp validate_static_amount(nil), do: :ok # Amount is optional for static QRs
12 defp validate_static_amount(amount) when is_binary(amount) do
13 case Decimal.parse(amount) do
14 {decimal_amount, ""} ->
15 if Decimal.compare(decimal_amount, Decimal.new("0")) in [:gt, :eq] do
16 :ok
17 else
18 {:error, "must be zero or positive"}
19 end
20
21 _ ->
22 {:error, "must be a valid decimal number"}
23 end
24 end
25
26 defp validate_static_amount(_), do: {:error, "must be a string"}
27
28 defp validate_currency(currency) when is_binary(currency) dortner can check QR status and details
29 """
30
31
:-(
use DaProductAppWeb, :controller
32
33 alias DaProductApp.QRValidation.Service
34 alias DaProductApp.Transactions.UpiInternationalService
35 alias DaProductApp.QRCode.Generator
36
37 require Logger
38
39 @doc """
40 Generate QR code for partner's merchant.
41 This creates a dynamic QR with international parameters.
42
43 POST /api/v1/qr-generate
44 """
45 def generate_qr(conn, params) do
46
:-(
case validate_qr_generation_params(params) do
47 {:ok, validated_params} ->
48
:-(
case UpiInternationalService.generate_international_qr(validated_params) do
49 {:ok, qr_data} ->
50
:-(
Logger.info("QR generated successfully for partner: #{validated_params["partner_id"]}")
51
52 # Generate base64 QR code image
53
:-(
qr_image_result = Generator.generate_upi_qr_base64(qr_data.qr_string, %{size: 300})
54
55
:-(
response_data = %{
56
:-(
qr_id: qr_data.id,
57
:-(
qr_string: qr_data.qr_string,
58
:-(
merchant_id: qr_data.merchant_id,
59
:-(
amount: qr_data.amount,
60
:-(
currency: qr_data.currency,
61
:-(
fx_rate: qr_data.fx_rate,
62
:-(
inr_amount: qr_data.inr_amount,
63
:-(
expires_at: qr_data.expires_at,
64
:-(
qr_url: build_qr_url(qr_data.qr_string)
65 }
66
67 # Add QR image if generation was successful
68
:-(
response_data = case qr_image_result do
69
:-(
{:ok, base64_image} -> Map.put(response_data, :qr_image_base64, base64_image)
70 {:error, reason} ->
71
:-(
Logger.warn("QR image generation failed: #{reason}")
72
:-(
response_data
73 end
74
75 conn
76 |> put_status(:created)
77
:-(
|> json(%{
78 success: true,
79 data: response_data,
80 message: "QR code generated successfully"
81 })
82
83 {:error, reason} ->
84
:-(
Logger.error("QR generation failed: #{inspect(reason)}")
85
86 conn
87 |> put_status(:unprocessable_entity)
88
:-(
|> json(%{
89 success: false,
90 error: "qr_generation_failed",
91 message: format_error(reason)
92 })
93 end
94
95 {:error, errors} ->
96 conn
97 |> put_status(:bad_request)
98
:-(
|> json(%{
99 success: false,
100 error: "validation_failed",
101 errors: errors,
102 message: "Invalid QR generation parameters"
103 })
104 end
105 end
106
107 @doc """
108 Generate static QR code for partner's merchant.
109 This creates a static QR with mode 01 and digital signature.
110
111 POST /api/v1/generate-static-qr
112 """
113 def generate_static_qr(conn, params) do
114 # Force qr_type to be static for this endpoint
115
:-(
static_params = Map.put(params, "qr_type", "static")
116
117
:-(
case validate_static_qr_params(static_params) do
118 {:ok, validated_params} ->
119
:-(
case UpiInternationalService.generate_international_qr(validated_params) do
120 {:ok, qr_data} ->
121
:-(
Logger.info("Static QR generated successfully for partner: #{validated_params["partner_id"]}")
122
123 # Generate base64 QR code image
124
:-(
qr_image_result = Generator.generate_upi_qr_base64(qr_data.qr_string, %{size: 300})
125
126
:-(
response_data = %{
127
:-(
qr_id: qr_data.id,
128
:-(
qr_string: qr_data.qr_string,
129
:-(
merchant_id: qr_data.merchant_id,
130
:-(
amount: qr_data.amount,
131
:-(
currency: qr_data.currency,
132
:-(
fx_rate: qr_data.fx_rate,
133
:-(
inr_amount: qr_data.inr_amount,
134 qr_type: "static",
135 initiation_mode: "01",
136 max_usage: Map.get(qr_data, :max_usage, "unlimited"),
137
:-(
qr_url: build_qr_url(qr_data.qr_string)
138 }
139
140 # Add QR image if generation was successful
141
:-(
response_data = case qr_image_result do
142
:-(
{:ok, base64_image} -> Map.put(response_data, :qr_image_base64, base64_image)
143 {:error, reason} ->
144
:-(
Logger.warn("QR image generation failed for static QR: #{reason}")
145
:-(
response_data
146 end
147
148 conn
149 |> put_status(:created)
150
:-(
|> json(%{
151 success: true,
152 data: response_data,
153 message: "Static QR code generated successfully"
154 })
155
156 {:error, reason} ->
157
:-(
Logger.error("Static QR generation failed: #{inspect(reason)}")
158
159 conn
160 |> put_status(:unprocessable_entity)
161
:-(
|> json(%{
162 success: false,
163 error: "static_qr_generation_failed",
164 message: format_error(reason)
165 })
166 end
167
168 {:error, errors} ->
169 conn
170 |> put_status(:bad_request)
171
:-(
|> json(%{
172 success: false,
173 error: "validation_failed",
174 errors: errors,
175 message: "Invalid static QR generation parameters"
176 })
177 end
178 end
179
180 @doc """
181 Get QR code status and details.
182 Partner can check their generated QR codes.
183
184 GET /api/v1/qr-status/:id
185 """
186 def get_qr_status(conn, %{"id" => qr_id}) do
187
:-(
case UpiInternationalService.get_qr_by_id(qr_id) do
188 {:ok, qr_data} ->
189
:-(
Logger.info("QR status retrieved for ID: #{qr_id}")
190
191 # Generate base64 QR code image if qr_string is available
192
:-(
qr_image_result = if Map.has_key?(qr_data, :qr_string) and qr_data.qr_string do
193
:-(
Generator.generate_upi_qr_base64(qr_data.qr_string, %{size: 300})
194 else
195 {:error, "QR string not available"}
196 end
197
198
:-(
response_data = %{
199
:-(
qr_id: qr_data.qr_id,
200
:-(
merchant_id: qr_data.merchant_id,
201
:-(
partner_id: qr_data.partner_id,
202
:-(
amount: qr_data.amount,
203
:-(
currency: qr_data.currency,
204
:-(
fx_rate: qr_data.fx_rate,
205
:-(
inr_amount: qr_data.inr_amount,
206
:-(
status: qr_data.status,
207
:-(
created_at: qr_data.created_at,
208
:-(
expires_at: qr_data.expires_at,
209
:-(
usage_count: qr_data.usage_count || 0,
210
:-(
max_usage: qr_data.max_usage || 1,
211
:-(
qr_string: qr_data.qr_string,
212
:-(
last_used_at: qr_data.last_used_at,
213
:-(
transactions: qr_data.transactions || []
214 }
215
216 # Add QR image if generation was successful
217
:-(
response_data = case qr_image_result do
218
:-(
{:ok, base64_image} -> Map.put(response_data, :qr_image_base64, base64_image)
219 {:error, reason} ->
220
:-(
Logger.warn("QR image generation failed for status check: #{reason}")
221
:-(
response_data
222 end
223
224 conn
225
:-(
|> json(%{
226 success: true,
227 data: response_data,
228 message: "QR status retrieved successfully"
229 })
230
231 {:error, :not_found} ->
232 conn
233 |> put_status(:not_found)
234
:-(
|> json(%{
235 success: false,
236 error: "qr_not_found",
237 message: "QR code not found"
238 })
239
240 {:error, reason} ->
241
:-(
Logger.error("Failed to retrieve QR status: #{inspect(reason)}")
242
243 conn
244 |> put_status(:internal_server_error)
245
:-(
|> json(%{
246 success: false,
247 error: "retrieval_failed",
248 message: "Failed to retrieve QR status"
249 })
250 end
251 end
252
253 # ================================
254 # Private Helper Functions
255 # ================================
256
257 defp validate_static_qr_params(params) do
258 # Normalize incoming params to accept nested partner_merchant_payload and camelCase keys
259
:-(
params = normalize_params(params)
260 # For static QRs, amount can be 0 (customer enters amount) or specific amount
261
:-(
required_fields = ["partner_id", "merchant_id", "currency"]
262
:-(
errors = []
263
264 # Check required fields
265
:-(
errors = Enum.reduce(required_fields, errors, fn field, acc ->
266
:-(
case Map.get(params, field) do
267
:-(
nil -> [%{field: field, message: "is required"} | acc]
268
:-(
"" -> [%{field: field, message: "cannot be empty"} | acc]
269
:-(
_ -> acc
270 end
271 end)
272
273 # Validate amount (optional for static QRs, can be 0 for customer-entered amount)
274
:-(
errors = case validate_static_amount(params["amount"]) do
275
:-(
:ok -> errors
276
:-(
{:error, message} -> [%{field: "amount", message: message} | errors]
277 end
278
279 # Validate currency
280
:-(
errors = case validate_currency(params["currency"]) do
281
:-(
:ok -> errors
282
:-(
{:error, message} -> [%{field: "currency", message: message} | errors]
283 end
284
285 # Validate corridor (partner's country)
286
:-(
errors = case validate_corridor(params["corridor"]) do
287
:-(
:ok -> errors
288
:-(
{:error, message} -> [%{field: "corridor", message: message} | errors]
289 end
290
291 # Extract customer_ref if present
292
:-(
validated_params = case Map.get(params, "customer_ref") do
293
:-(
nil -> params
294
:-(
"" -> params
295 customer_ref when is_binary(customer_ref) ->
296
:-(
Map.put(params, "customer_ref", String.trim(customer_ref))
297 _ ->
298
:-(
errors = [%{field: "customer_ref", message: "must be a string"} | errors]
299
:-(
params
300 end
301
302 # Force static QR settings
303
:-(
metadata = Map.get(validated_params, "metadata", %{})
304
:-(
metadata_with_static = Map.merge(metadata, %{
305 "qr_type" => "static",
306 "initiation_mode" => "01"
307 })
308
309 # Set default values for static QRs
310
:-(
validated_params = Map.merge(validated_params, %{
311 "metadata" => metadata_with_static,
312 "max_usage_count" => 999999, # High number for unlimited usage
313 "validity_minutes" => 365 * 24 * 60, # 1 year validity for static QRs
314
:-(
"amount" => params["amount"] || "0.00" # Default to 0 for customer-entered amount
315 })
316
317
:-(
case errors do
318
:-(
[] -> {:ok, validated_params}
319
:-(
_ -> {:error, Enum.reverse(errors)}
320 end
321 end
322
323 defp validate_qr_generation_params(params) do
324 # Normalize incoming params to accept nested partner_merchant_payload and camelCase keys
325
:-(
params = normalize_params(params)
326
:-(
required_fields = ["partner_id", "merchant_id", "amount", "currency"]
327
:-(
errors = []
328
329 # Check required fields
330
:-(
errors = Enum.reduce(required_fields, errors, fn field, acc ->
331
:-(
case Map.get(params, field) do
332
:-(
nil -> [%{field: field, message: "is required"} | acc]
333
:-(
"" -> [%{field: field, message: "cannot be empty"} | acc]
334
:-(
_ -> acc
335 end
336 end)
337
338 # Validate qr_type if provided
339
:-(
errors = case validate_qr_type(params["qr_type"]) do
340
:-(
:ok -> errors
341
:-(
{:error, message} -> [%{field: "qr_type", message: message} | errors]
342 end
343
344 # Validate amount
345
:-(
errors = case validate_amount(params["amount"]) do
346
:-(
:ok -> errors
347
:-(
{:error, message} -> [%{field: "amount", message: message} | errors]
348 end
349
350 # Validate currency
351
:-(
errors = case validate_currency(params["currency"]) do
352
:-(
:ok -> errors
353
:-(
{:error, message} -> [%{field: "currency", message: message} | errors]
354 end
355
356 # Validate corridor (partner's country)
357
:-(
errors = case validate_corridor(params["corridor"]) do
358
:-(
:ok -> errors
359
:-(
{:error, message} -> [%{field: "corridor", message: message} | errors]
360 end
361
362 # Extract and validate customer_ref if present
363
:-(
validated_params = case Map.get(params, "customer_ref") do
364
:-(
nil -> params
365
:-(
"" -> params
366 customer_ref when is_binary(customer_ref) ->
367
:-(
Map.put(params, "customer_ref", String.trim(customer_ref))
368 _ ->
369
:-(
errors = [%{field: "customer_ref", message: "must be a string"} | errors]
370
:-(
params
371 end
372
373 # Add qr_type to metadata
374
:-(
qr_type = Map.get(params, "qr_type", "dynamic") # Default to dynamic
375
:-(
metadata = Map.get(validated_params, "metadata", %{})
376
:-(
metadata_with_qr_type = Map.put(metadata, "qr_type", qr_type)
377
:-(
validated_params = Map.put(validated_params, "metadata", metadata_with_qr_type)
378
379
:-(
case errors do
380
:-(
[] -> {:ok, validated_params}
381
:-(
_ -> {:error, Enum.reverse(errors)}
382 end
383 end
384
385 defp validate_amount(amount) when is_binary(amount) do
386
:-(
case Decimal.parse(amount) do
387 {decimal_amount, ""} ->
388
:-(
if Decimal.positive?(decimal_amount) do
389 :ok
390 else
391 {:error, "must be positive"}
392 end
393
394
:-(
_ ->
395 {:error, "must be a valid decimal number"}
396 end
397 end
398
399
:-(
defp validate_amount(_), do: {:error, "must be a string"}
400
401
:-(
defp validate_static_amount(nil), do: :ok # Amount is optional for static QRs
402
:-(
defp validate_static_amount(""), do: :ok # Empty amount means customer enters amount
403
:-(
defp validate_static_amount("0.00"), do: :ok # Zero amount means customer enters amount
404
:-(
defp validate_static_amount("0"), do: :ok # Zero amount means customer enters amount
405 defp validate_static_amount(amount) when is_binary(amount) do
406
:-(
case Decimal.parse(amount) do
407 {decimal_amount, ""} ->
408
:-(
if Decimal.compare(decimal_amount, Decimal.new("0")) in [:gt, :eq] do
409 :ok
410 else
411 {:error, "must be zero or positive"}
412 end
413
414
:-(
_ ->
415 {:error, "must be a valid decimal number"}
416 end
417 end
418
419
:-(
defp validate_static_amount(_), do: {:error, "must be a string"}
420
421 defp validate_currency(currency) when is_binary(currency) do
422
:-(
valid_currencies = ["SGD", "USD", "AED", "EUR", "GBP"]
423
424
:-(
if currency in valid_currencies do
425 :ok
426 else
427 {:error, "must be one of: #{Enum.join(valid_currencies, ", ")}"}
428 end
429 end
430
431
:-(
defp validate_currency(_), do: {:error, "must be a string"}
432
433
:-(
defp validate_corridor(nil), do: :ok # Optional field
434 defp validate_corridor(corridor) when is_binary(corridor) do
435
:-(
valid_corridors = ["singapore", "uae", "usa", "uk", "eu"]
436
437
:-(
if corridor in valid_corridors do
438 :ok
439 else
440 {:error, "must be one of: #{Enum.join(valid_corridors, ", ")}"}
441 end
442 end
443
444
:-(
defp validate_corridor(_), do: {:error, "must be a string"}
445
446
:-(
defp validate_qr_type(nil), do: :ok # Optional field, defaults to dynamic
447 defp validate_qr_type(qr_type) when is_binary(qr_type) do
448
:-(
valid_types = ["static", "dynamic"]
449
450
:-(
if qr_type in valid_types do
451 :ok
452 else
453 {:error, "must be one of: #{Enum.join(valid_types, ", ")}"}
454 end
455 end
456
457
:-(
defp validate_qr_type(_), do: {:error, "must be a string"}
458
459 defp build_qr_url(qr_string) do
460 # Generate a URL that can be used to display QR code
461
:-(
base_url = Application.get_env(:da_product_app, :qr_base_url, "https://mercurypay.ariticapp.com")
462
:-(
"#{base_url}/qr/#{Base.url_encode64(qr_string)}"
463 end
464
465 # Normalize keys (camelCase -> snake_case) and merge partner_merchant_payload
466 # so the UI that sends nested payloads is accepted. Top-level params take precedence.
467 defp normalize_params(params) when is_map(params) do
468 # First, normalize all keys in the incoming map (recursively for nested maps)
469
:-(
normalized =
470 params
471 |> Enum.map(fn {k, v} ->
472
:-(
key = if is_binary(k), do: normalize_key(k), else: k
473
:-(
val = if is_map(v), do: normalize_params(v), else: v
474 {key, val}
475 end)
476
:-(
|> Enum.into(%{})
477
478 # If there's a nested partner_merchant_payload, merge it into top-level params
479
:-(
case Map.get(normalized, "partner_merchant_payload") do
480
:-(
m when is_map(m) -> Map.merge(m, normalized)
481 m when is_binary(m) ->
482 # Sometimes UI sends nested JSON as a string; try decode
483
:-(
case Jason.decode(m) do
484 {:ok, decoded} when is_map(decoded) ->
485
:-(
decoded_norm = normalize_params(decoded)
486
:-(
Map.merge(decoded_norm, normalized)
487
:-(
_ -> normalized
488 end
489
:-(
_ -> normalized
490 end
491 end
492
493
:-(
defp normalize_params(other), do: other
494
495 defp normalize_key(key) when is_binary(key) do
496 # Convert camelCase keys like "merchantName" or "payeeVPA" to snake_case
497
:-(
try do
498
:-(
Macro.underscore(key)
499 rescue
500
:-(
_ -> key
501 end
502 end
503
504
:-(
defp format_error(reason) when is_binary(reason), do: reason
505
:-(
defp format_error(reason), do: inspect(reason)
506 end
Line Hits Source