cover/Elixir.DaProductApp.Transactions.UpiInternationalService.html

1 defmodule DaProductApp.Transactions.UpiInternationalService do
2 @moduledoc """
3 Enhanced UPI International Service with complete specification compliance.
4
5 Handles all UPI PSP operations including:
6 - Transaction lifecycle management
7 - Partner integrations
8 - State management
9 - Reconciliation
10 - Mandate processing
11 """
12
13 import Ecto.Query
14 alias Ecto.Multi
15 alias DaProductApp.Repo
16 alias DaProductApp.Crypto.Signature
17 alias DaProductApp.Transactions.{Transaction, TransactionEvent}
18 alias DaProductApp.ForeignExchange.FxRateService
19 alias DaProductApp.Adapters.SandboxPartner
20 alias DaProductApp.Merchants
21 alias Timex
22
23 @credit_wait_ms 30_000
24 @reversal_wait_ms 30_000
25
26 # New functions for complete UPI implementation
27
28 @doc """
29 Generate international QR code for partner requests.
30 Handles FX conversion and creates dynamic QR with all required parameters.
31 """
32 def generate_international_qr(params) do
33
:-(
try do
34 # Determine markup and fixed fees: allow caller to provide them, otherwise fall back to corridor defaults
35
:-(
markup_pct = params["markup_percent"] || get_markup_percentage(params["corridor"])
36
:-(
fixed_fees = params["fixed_fees"] || 0
37
38
:-(
with {:ok, fx_rate} <- get_fx_rate_for_corridor(params["currency"], params["corridor"]),
39
:-(
{:ok, inr_amount} <- calculate_inr_amount(params["amount"], fx_rate, %{markup_pct: markup_pct, fixed_fees: fixed_fees}),
40
:-(
{:ok, qr_data} <- create_qr_record(params, fx_rate, inr_amount),
41
:-(
{:ok, qr_string} <- build_upi_qr_string(qr_data) do
42
43
:-(
qr_response = %{
44
:-(
id: qr_data.qr_id,
45 qr_string: qr_string,
46 merchant_id: params["merchant_id"],
47 amount: params["amount"],
48 currency: params["currency"],
49 fx_rate: fx_rate,
50 markup_percentage: get_markup_percentage(params["corridor"]),
51 inr_amount: inr_amount,
52
:-(
expires_at: calculate_expiry(params["validity_minutes"] || 300),
53 status: "active"
54 }
55
56 {:ok, qr_response}
57 else
58
:-(
{:error, reason} -> {:error, reason}
59
:-(
error -> {:error, "QR generation failed: #{inspect(error)}"}
60 end
61 rescue
62
:-(
e -> {:error, "QR generation error: #{Exception.message(e)}"}
63 end
64 end
65
66 @doc """
67 Get QR code details by ID for status checking.
68 """
69 def get_qr_by_id(qr_id) do
70
:-(
try do
71 # For now, we'll simulate QR data since we don't have persistent storage yet
72 # In production, this would query the QR database table
73
:-(
if String.starts_with?(qr_id, "QR_") do
74 # Parse QR ID to extract info (temporary simulation)
75
:-(
qr_data = %{
76 qr_id: qr_id,
77 merchant_id: "MERCHANT_SG_123",
78 partner_id: "SINGAPORE_PARTNER_001",
79 amount: "100.50",
80 currency: "SGD",
81 fx_rate: "83.25",
82 inr_amount: "8325.00",
83 status: determine_qr_status(qr_id),
84 created_at: DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.to_iso8601(),
85 expires_at: DateTime.utc_now() |> DateTime.add(300, :second) |> DateTime.to_iso8601(),
86 usage_count: 0,
87 max_usage: 1,
88 qr_string: "upiGlobal://pay?ver=01&mode=16&purpose=11&orgid=700004&tr=QR_SG_TEST&tn=MT_TEST&pa=merchant@mercury&pn=Singapore%20Test%20Merchant&mc=5411&cu=SGD&mid=MERCHANT_SG_123&msid=SINGAPORE_PARTNER_001001&mtid=SINGAPORE_PARTNER_001T01&mType=SMALL&mGr=OFFLINE&mOnboarding=AGGREGATOR&mLoc=SG&brand=Singapore%20Test%20Merchant&cc=SG&bAm=100.50&bCurr=SGD&qrMedium=03&invoiceNo=test123&invoiceDate=2025-09-12T10:30:00Z&invoiceName=Singapore%20Test%20Merchant&QRexpire=2025-09-12T15:30:00Z&QRts=2025-09-12T10:30:00Z&sign=testSignature123",
89 last_used_at: nil,
90 transactions: []
91 }
92
93 {:ok, qr_data}
94 else
95 {:error, :not_found}
96 end
97 rescue
98
:-(
_e -> {:error, :invalid_qr_id}
99 end
100 end
101
102 @doc """
103 Create a new transaction with enhanced tracking
104 """
105 def create_transaction(transaction_data) do
106 # Use appropriate changeset based on transaction type
107
:-(
changeset = case transaction_data.transaction_type do
108 "DOMESTIC" ->
109
:-(
Transaction.domestic_changeset(%Transaction{}, transaction_data)
110 "INTERNATIONAL" ->
111
:-(
Transaction.international_changeset(%Transaction{}, transaction_data)
112 _ ->
113
:-(
Transaction.changeset(%Transaction{}, transaction_data)
114 end
115
116 Multi.new()
117 |> Multi.insert(:transaction, changeset)
118 |> Multi.run(:initial_event, fn _repo, %{transaction: txn} ->
119
:-(
append_event(txn.id, :transaction_created, %{
120
:-(
type: transaction_data.transaction_type,
121
:-(
status: transaction_data.status,
122
:-(
amount: transaction_data.inr_amount
123 })
124 end)
125 |> Repo.transaction()
126
:-(
|> case do
127
:-(
{:ok, %{transaction: transaction}} ->
128 {:ok, transaction}
129 {:error, :transaction, %Ecto.Changeset{} = changeset, _changes} ->
130 # Check if it's a unique constraint error on org_txn_id
131
:-(
case changeset.errors do
132 [org_txn_id: {"has already been taken", _}] ->
133 # Return existing transaction
134
:-(
existing = get_transaction_by_org_id(transaction_data.org_txn_id)
135 {:ok, existing}
136
:-(
_ ->
137 {:error, changeset}
138 end
139
:-(
{:error, _operation, reason, _changes} ->
140 {:error, reason}
141 end
142 end
143
144 @doc """
145 Update transaction status with event tracking
146 """
147
:-(
def update_transaction_status(txn_id, status, response_data \\ %{}) do
148 Multi.new()
149 |> Multi.run(:transaction, fn _repo, _changes ->
150 # Resolve txn record safely. callers sometimes pass a generated string id
151 # (eg. "TXN3F26D3DFF...") which is NOT the DB primary key (:id integer).
152 # Avoid calling Repo.get/2 with a non-integer value (it raises CastError).
153
:-(
txn =
154 cond do
155
:-(
is_integer(txn_id) -> Repo.get(Transaction, txn_id)
156
157
:-(
is_binary(txn_id) ->
158 # If the string is numeric, try integer id lookup first
159
:-(
case Integer.parse(txn_id) do
160
:-(
{int_id, ""} -> Repo.get(Transaction, int_id)
161 _ ->
162 # Fallback: try org_txn_id (external transaction identifier)
163
:-(
Repo.get_by(Transaction, org_txn_id: txn_id)
164 end
165
166
:-(
true -> nil
167 end
168
169
:-(
case txn do
170
:-(
nil -> {:error, :not_found}
171 txn ->
172
:-(
changeset = Ecto.Changeset.change(txn, %{
173 status: Atom.to_string(status),
174 updated_at: DateTime.utc_now() |> DateTime.truncate(:second)
175 })
176
:-(
case Repo.update(changeset) do
177
:-(
{:ok, updated_txn} -> {:ok, updated_txn}
178
:-(
error -> error
179 end
180 end
181 end)
182 |> Multi.run(:status_event, fn _repo, %{transaction: txn} ->
183
:-(
append_event(txn.id, :status_changed, %{
184
:-(
old_status: txn.status,
185 new_status: Atom.to_string(status),
186 response_data: response_data
187 })
188 end)
189 |> Repo.transaction()
190
:-(
|> case do
191
:-(
{:ok, %{transaction: transaction}} -> {:ok, transaction}
192
:-(
{:error, _operation, reason, _changes} -> {:error, reason}
193 end
194 end
195
196 @doc """
197 Process with partner (enhanced with better error handling)
198 """
199 def process_with_partner(transaction) do
200
:-(
try do
201
:-(
case SandboxPartner.process_international_payment(%{
202
:-(
amount: transaction.inr_amount,
203
:-(
currency: transaction.currency,
204
:-(
merchant_id: transaction.payee_mid,
205
:-(
reference: transaction.org_txn_id
206 }) do
207
:-(
{:ok, response} ->
208 {:ok, %{
209
:-(
partner_txn_id: response.transaction_id,
210
:-(
fx_rate: response.fx_rate,
211
:-(
converted_amount: response.converted_amount,
212
:-(
status: response.status
213 }}
214
215
:-(
{:error, :timeout} ->
216 {:error, :timeout}
217
218
:-(
{:error, _reason} ->
219 {:error, :partner_error}
220 end
221 rescue
222
:-(
_error -> {:error, :partner_error}
223 end
224 end
225
226 @doc """
227 Reverse transaction with partner
228 """
229 def reverse_transaction(transaction) do
230
:-(
case SandboxPartner.reverse_payment(%{
231
:-(
original_txn_id: transaction.org_txn_id,
232
:-(
amount: transaction.inr_amount,
233 reason: "UPI Reversal Request"
234 }) do
235
:-(
{:ok, response} ->
236 {:ok, %{
237
:-(
reversal_txn_id: response.reversal_id,
238
:-(
status: response.status,
239
:-(
reversed_amount: response.amount
240 }}
241
242
:-(
{:error, _reason} ->
243 {:error, :reversal_failed}
244 end
245 end
246
247 @doc """
248 Get transactions for reconciliation
249 """
250 def get_transactions_for_reconciliation(from_date, to_date) do
251
:-(
try do
252
:-(
from_datetime = parse_date_string(from_date)
253
:-(
to_datetime = parse_date_string(to_date)
254
255
:-(
query = from(t in Transaction,
256 where: t.inserted_at >= ^from_datetime and t.inserted_at <= ^to_datetime,
257 order_by: [desc: t.inserted_at]
258 )
259
260
:-(
transactions = Repo.all(query)
261 {:ok, transactions}
262 rescue
263
:-(
error -> {:error, "Date parsing failed: #{inspect(error)}"}
264 end
265 end
266
267 @doc """
268 Process mandate request
269 """
270 def process_mandate(mandate_data) do
271 # Create mandate record
272
:-(
mandate = %{
273 id: generate_mandate_id(),
274
:-(
mandate_id: mandate_data.mandate_id,
275
:-(
amount: mandate_data.amount,
276
:-(
frequency: mandate_data.frequency,
277 status: "ACTIVE",
278
:-(
valid_until: calculate_mandate_expiry(mandate_data.frequency),
279 created_at: DateTime.utc_now()
280 }
281
282 # In a real implementation, this would be stored in a mandates table
283 {:ok, mandate}
284 end
285
286 # Enhanced existing functions
287
288 @doc """
289 Get transaction by org_txn_id
290 """
291 def get_transaction_by_org_id(org_txn_id) do
292 # Query the raw table and select only fields we need to avoid loading the full
293 # Transaction schema (which may reference DB columns not present in all envs).
294
:-(
query = from(t in "transactions",
295 where: field(t, :org_txn_id) == ^org_txn_id,
296 select: %{
297 id: field(t, :id),
298 org_txn_id: field(t, :org_txn_id),
299 status: field(t, :status),
300 currency: field(t, :currency),
301 inr_amount: field(t, :inr_amount),
302 inserted_at: field(t, :inserted_at),
303 updated_at: field(t, :updated_at)
304 }
305 )
306
307
:-(
Repo.one(query)
308 end
309
310 @doc """
311 Process incoming NPCI credit request (ReqPay) for international merchant.
312
313 At this point:
314 - NPCI has already debited INR from customer
315 - NPCI is asking your PSP to credit the international merchant
316 - Your PSP needs to convert INR to foreign currency and credit partner
317 """
318 def process_npci_credit_request(npci_req_pay_attrs) do
319 Multi.new()
320 |> Multi.run(:fx_conversion, fn _repo, _changes ->
321
:-(
convert_inr_to_foreign_currency(npci_req_pay_attrs)
322 end)
323 |> Multi.run(:transaction, fn _repo, %{fx_conversion: fx_data} ->
324
:-(
create_international_transaction(npci_req_pay_attrs, fx_data)
325 end)
326 |> Multi.run(:npci_received_event, fn _repo, %{transaction: txn} ->
327
:-(
append_event(txn.id, :npci_inr_received, %{
328
:-(
inr_amount: txn.inr_amount,
329 received_from: "NPCI",
330 customer_debited: true
331 })
332 end)
333 |> Multi.run(:credit_partner, fn _repo, %{transaction: txn, fx_conversion: fx_data} ->
334
:-(
credit_international_partner(txn, fx_data)
335 end)
336 |> Repo.transaction()
337
:-(
|> case do
338
:-(
{:ok, %{transaction: txn}} -> {:ok, txn}
339
:-(
{:error, step, reason, _} -> {:error, {step, reason}}
340 end
341 end
342
343 @doc """
344 Handle timeout scenario - partner didn't respond to credit request
345 """
346 def handle_partner_timeout(%Transaction{} = txn) do
347 Multi.new()
348 |> Multi.run(:timeout_event, fn _repo, _changes ->
349
:-(
append_event(txn.id, :partner_credit_timeout, %{
350 timeout_after_ms: @credit_wait_ms,
351
:-(
partner_adapter: get_partner_adapter(txn.corridor)
352 })
353 end)
354 |> Multi.run(:check_status, fn _repo, _changes ->
355
:-(
check_partner_transaction_status(txn)
356 end)
357 |> Multi.run(:handle_check_result, fn _repo, %{check_status: status_result} ->
358
:-(
case status_result do
359
:-(
{:ok, %{code: "CS"}} -> mark_delayed_success(txn)
360
:-(
{:ok, %{code: "00"}} -> handle_partner_reversal(txn)
361
:-(
{:error, _} -> initiate_partner_reversal(txn)
362 end
363 end)
364
:-(
|> Repo.transaction()
365 end
366
367 @doc """
368 Initiate partner reversal when status check fails
369 """
370 def initiate_partner_reversal(%Transaction{} = txn) do
371 Multi.new()
372 |> Multi.run(:timeout_event, fn _repo, _changes ->
373
:-(
append_event(txn.id, :partner_timeout_detected, %{
374 timeout_after_seconds: 30,
375
:-(
last_known_status: txn.current_state
376 })
377 end)
378 |> Multi.run(:reversal_decision, fn _repo, _changes ->
379
:-(
append_event(txn.id, :partner_reversal_initiated, %{
380 reason: "partner_status_check_failed",
381
:-(
foreign_amount_to_reverse: txn.foreign_amount
382 })
383 end)
384 |> Multi.run(:update_status, fn _repo, _changes ->
385 # Update transaction to reversal pending state
386 Transaction.changeset(txn, %{current_state: "reversal_pending"})
387
:-(
|> Repo.update()
388 end)
389
:-(
|> Repo.transaction()
390 end
391
392 @doc """
393 Handle partner reversal - partner couldn't credit merchant, reverse to customer
394 """
395 def handle_partner_reversal(%Transaction{} = txn) do
396 Multi.new()
397 |> Multi.run(:partner_reversal_event, fn _repo, _changes ->
398
:-(
append_event(txn.id, :partner_reversal_initiated, %{
399
:-(
foreign_amount_to_reverse: txn.foreign_amount,
400
:-(
inr_amount_to_reverse: txn.inr_amount
401 })
402 end)
403 |> Multi.run(:reverse_with_partner, fn _repo, _changes ->
404
:-(
request_partner_reversal(txn)
405 end)
406 |> Multi.run(:npci_reversal, fn _repo, %{reverse_with_partner: partner_result} ->
407
:-(
case partner_result do
408
:-(
{:ok, _} -> initiate_npci_customer_reversal(txn)
409
:-(
{:error, reason} -> {:error, {:partner_reversal_failed, reason}}
410 end
411 end)
412 |> Multi.run(:final_status, fn _repo, %{npci_reversal: reversal_result} ->
413
:-(
case reversal_result do
414
:-(
{:ok, _} -> mark_transaction_reversed(txn)
415
:-(
{:error, _} -> mark_transaction_deemed(txn)
416 end
417 end)
418
:-(
|> Repo.transaction()
419 end
420
421 # Private helper functions for enhanced functionality
422
423 defp parse_date_string(date_string) do
424
:-(
case DateTime.from_iso8601(date_string) do
425
:-(
{:ok, datetime, _} -> datetime
426 {:error, _} ->
427 # Try alternative format: YYYY-MM-DD
428
:-(
case Date.from_iso8601(date_string) do
429
:-(
{:ok, date} -> DateTime.new!(date, ~T[00:00:00])
430
:-(
{:error, _} -> raise "Invalid date format: #{date_string}"
431 end
432 end
433 end
434
435 defp generate_mandate_id do
436
:-(
"MND" <> (:crypto.strong_rand_bytes(8) |> Base.encode16())
437 end
438
439 defp calculate_mandate_expiry(frequency) do
440
:-(
case frequency do
441
:-(
"DAILY" -> DateTime.add(DateTime.utc_now(), 30, :day)
442
:-(
"WEEKLY" -> DateTime.add(DateTime.utc_now(), 52 * 7, :day)
443
:-(
"MONTHLY" -> DateTime.add(DateTime.utc_now(), 12 * 30, :day)
444
:-(
"YEARLY" -> DateTime.add(DateTime.utc_now(), 365, :day)
445
:-(
_ -> DateTime.add(DateTime.utc_now(), 30, :day)
446 end
447 end
448
449 # Existing private functions preserved
450
451 defp convert_inr_to_foreign_currency(npci_attrs) do
452
:-(
inr_amount = npci_attrs.amount || npci_attrs.inr_amount
453
:-(
corridor = npci_attrs.corridor
454
:-(
target_currency = get_corridor_currency(corridor)
455
456
:-(
case FxRateService.convert_inr_to_foreign(inr_amount, target_currency, corridor) do
457
:-(
{:ok, fx_data} -> {:ok, fx_data}
458
:-(
{:error, reason} -> {:error, {:fx_conversion_failed, reason}}
459 end
460 end
461
462 defp create_international_transaction(npci_attrs, fx_data) do
463
:-(
transaction_attrs = %{
464
:-(
org_txn_id: npci_attrs.org_txn_id,
465
:-(
payer_addr: npci_attrs.payer_addr,
466
:-(
payer_name: npci_attrs.payer_name,
467
:-(
payee_addr: npci_attrs.payee_addr,
468
:-(
payee_name: npci_attrs.payee_name,
469
470 # INR amounts (received from NPCI)
471
:-(
inr_amount: fx_data.inr_amount,
472 currency: "INR",
473
474 # Foreign currency amounts (to be sent to partner)
475
:-(
foreign_amount: fx_data.foreign_amount,
476
:-(
foreign_currency: fx_data.foreign_currency,
477
:-(
fx_rate: fx_data.fx_rate,
478
:-(
markup_rate: fx_data.markup_rate,
479
:-(
corridor: fx_data.corridor,
480
481 # Transaction state
482 current_state: "npci_received",
483 status: "processing",
484 transaction_type: "INTERNATIONAL",
485
486 # Metadata
487 npci_received_at: DateTime.utc_now(),
488
:-(
fx_locked_at: fx_data.rate_timestamp
489 }
490
491 %Transaction{}
492 |> Transaction.changeset(transaction_attrs)
493
:-(
|> Repo.insert()
494 end
495
496 defp credit_international_partner(txn, fx_data) do
497
:-(
partner_adapter = get_partner_adapter(txn.corridor)
498
499
:-(
credit_params = %{
500
:-(
org_txn_id: txn.org_txn_id,
501
:-(
merchant_account_id: get_merchant_account_id(txn.payee_addr),
502
:-(
base_amount: fx_data.foreign_amount,
503
:-(
base_currency: fx_data.foreign_currency,
504
:-(
inr_amount: fx_data.inr_amount,
505
:-(
fx_rate: fx_data.fx_rate,
506
:-(
corridor: fx_data.corridor
507 }
508
509
:-(
case apply(partner_adapter, :credit_merchant, [credit_params]) do
510 {:ok, %{code: "CS"} = response} ->
511 # Partner credit initiated successfully
512
:-(
update_transaction_and_event(txn, :partner_credit_initiated, %{
513 current_state: "credit_pending",
514
:-(
partner_response: response.payload,
515 partner_credited_at: DateTime.utc_now()
516 })
517
518 {:ok, %{code: "00"} = response} ->
519 # Immediate success
520
:-(
update_transaction_and_event(txn, :partner_credit_success, %{
521 current_state: "success",
522 status: "success",
523
:-(
partner_response: response.payload,
524 partner_credited_at: DateTime.utc_now(),
525 completed_at: DateTime.utc_now()
526 })
527
528
:-(
{:error, reason} ->
529 # Partner credit failed - need to reverse
530 {:error, {:partner_credit_failed, reason}}
531 end
532 end
533
534 defp check_partner_transaction_status(txn) do
535
:-(
partner_adapter = get_partner_adapter(txn.corridor)
536
537
:-(
check_params = %{
538
:-(
org_txn_id: txn.org_txn_id,
539 partner_txn_id: get_partner_txn_id(txn)
540 }
541
542
:-(
apply(partner_adapter, :check_transaction_status, [check_params])
543 end
544
545 defp request_partner_reversal(txn) do
546
:-(
partner_adapter = get_partner_adapter(txn.corridor)
547
548
:-(
reversal_params = %{
549
:-(
org_txn_id: txn.org_txn_id,
550
:-(
base_amount: txn.foreign_amount,
551
:-(
base_currency: txn.foreign_currency,
552
:-(
inr_amount: txn.inr_amount,
553 reason: "TIMEOUT_REVERSAL"
554 }
555
556
:-(
apply(partner_adapter, :reverse_payment, [reversal_params])
557 end
558
559 defp initiate_npci_customer_reversal(txn) do
560 # Send reversal request to NPCI to credit back customer's account
561 # This would integrate with your NPCI messaging system
562
563
:-(
_reversal_params = %{
564
:-(
original_txn_id: txn.org_txn_id,
565
:-(
reversal_amount: txn.inr_amount,
566
:-(
customer_account: txn.payer_addr,
567 reason: "MERCHANT_CREDIT_FAILED"
568 }
569
570 # Simulate NPCI reversal success
571
:-(
{:ok, %{npci_reversal_ref: "REV_#{:rand.uniform(999999)}"}}
572 end
573
574 defp mark_delayed_success(txn) do
575
:-(
update_transaction_and_event(txn, :delayed_success, %{
576 current_state: "success",
577 status: "success",
578 completed_at: DateTime.utc_now()
579 })
580 end
581
582 defp mark_transaction_reversed(txn) do
583
:-(
update_transaction_and_event(txn, :transaction_reversed, %{
584 current_state: "reversed",
585 status: "reversed",
586 completed_at: DateTime.utc_now()
587 })
588 end
589
590 defp mark_transaction_deemed(txn) do
591
:-(
update_transaction_and_event(txn, :transaction_deemed, %{
592 current_state: "deemed",
593 status: "deemed",
594 completed_at: DateTime.utc_now()
595 })
596 end
597
598 defp update_transaction_and_event(txn, event_type, update_attrs) do
599 Multi.new()
600 |> Multi.update(:transaction, Transaction.changeset(txn, update_attrs))
601 |> Multi.run(:event, fn _repo, %{transaction: updated_txn} ->
602
:-(
append_event(updated_txn.id, event_type, update_attrs)
603 end)
604 |> Repo.transaction()
605
:-(
|> case do
606
:-(
{:ok, %{transaction: updated_txn}} -> {:ok, updated_txn}
607
:-(
{:error, step, reason, _} -> {:error, {step, reason}}
608 end
609 end
610
611 defp append_event(txn_id, event_type, event_data) do
612
:-(
max_seq = get_max_seq(txn_id)
613
:-(
prev_hash = get_latest_hash(txn_id)
614
615 # Generate a simple hash for the event
616
:-(
event_hash = :crypto.hash(:sha256, "#{txn_id}_#{max_seq + 1}_#{event_type}")
617
618 %TransactionEvent{}
619 |> TransactionEvent.changeset(%{
620 transaction_id: txn_id,
621 event_type: Atom.to_string(event_type),
622 seq: max_seq + 1,
623 payload: event_data,
624 prev_hash: prev_hash,
625 hash: event_hash,
626 inserted_at: DateTime.utc_now() |> DateTime.truncate(:second)
627 })
628
:-(
|> Repo.insert()
629 end
630
631 defp get_max_seq(txn_id) do
632 from(e in TransactionEvent,
633 where: e.transaction_id == ^txn_id,
634 select: max(e.seq))
635
:-(
|> Repo.one() || 0
636 end
637
638 defp get_latest_hash(txn_id) do
639 from(e in TransactionEvent,
640 where: e.transaction_id == ^txn_id,
641 order_by: [desc: e.seq],
642 limit: 1,
643 select: e.hash)
644
:-(
|> Repo.one()
645 end
646
647 # Configuration helpers
648 defp get_corridor_currency(corridor) do
649
:-(
case corridor do
650
:-(
"SINGAPORE" -> "SGD"
651
:-(
"UAE" -> "AED"
652
:-(
"USA" -> "USD"
653
:-(
_ -> raise "Unsupported corridor: #{corridor}"
654 end
655 end
656
657 defp get_partner_adapter(corridor) do
658
:-(
case corridor do
659
:-(
"SINGAPORE" -> SandboxPartner # In production: SingaporePartner
660
:-(
"UAE" -> SandboxPartner # In production: UAEPartner
661
:-(
"USA" -> SandboxPartner # In production: USAPartner
662
:-(
_ -> raise "No adapter for corridor: #{corridor}"
663 end
664 end
665
666 defp get_merchant_account_id(payee_addr) do
667 # Extract merchant account from VPA or lookup in merchant registry
668
:-(
"MERCH_" <> String.replace(payee_addr, "@", "_")
669 end
670
671 defp get_partner_txn_id(txn) do
672 # Extract partner transaction ID from previous events or response
673
:-(
"PARTNER_TXN_#{txn.id}"
674 end
675
676 # QR Generation Helper Functions
677
678 defp determine_qr_status(qr_id) do
679 # Simulate status based on QR ID patterns (for testing)
680
:-(
cond do
681
:-(
String.contains?(qr_id, "EXPIRED") -> "expired"
682
:-(
String.contains?(qr_id, "USED") -> "used"
683
:-(
true -> "active"
684 end
685 end
686
687 defp get_fx_rate_for_corridor(currency, corridor) do
688
:-(
try do
689
:-(
case FxRateService.get_rate(currency, "INR") do
690 {:ok, rate_data} ->
691
:-(
base_rate = Decimal.to_float(rate_data.rate)
692
:-(
markup = get_markup_percentage(corridor)
693
:-(
final_rate = base_rate * (1 + markup / 100)
694 {:ok, Float.round(final_rate, 2)}
695
696 {:error, _reason} ->
697 # Fallback to default rates for testing
698
:-(
default_rate = get_default_fx_rate(currency)
699
:-(
markup = get_markup_percentage(corridor)
700
:-(
final_rate = default_rate * (1 + markup / 100)
701 {:ok, Float.round(final_rate, 2)}
702 end
703 rescue
704
:-(
_e ->
705 # Fallback to default rates
706
:-(
default_rate = get_default_fx_rate(currency)
707
:-(
markup = get_markup_percentage(corridor)
708
:-(
final_rate = default_rate * (1 + markup / 100)
709 {:ok, Float.round(final_rate, 2)}
710 end
711 end
712
713 defp get_default_fx_rate(currency) do
714
:-(
case currency do
715
:-(
"SGD" -> 81.25
716
:-(
"USD" -> 83.50
717
:-(
"AED" -> 22.75
718
:-(
"EUR" -> 89.40
719
:-(
"GBP" -> 104.25
720
:-(
_ -> 80.00 # Default fallback
721 end
722 end
723
724 defp get_markup_percentage(corridor) do
725
:-(
case corridor do
726
:-(
"singapore" -> 2.5
727
:-(
"uae" -> 3.0
728
:-(
"usa" -> 2.0
729
:-(
"europe" -> 2.8
730
:-(
"uk" -> 3.2
731
:-(
_ -> 2.5 # Default markup
732 end
733 end
734
735 defp calculate_inr_amount(foreign_amount, fx_rate, opts \\ %{}) do
736 # Formula: am = round( (bAm × fxRate + fixedFees) × (1 + markupPercent/100), 2 )
737
:-(
try do
738 # helpers to coerce different input types into Decimal
739
:-(
to_decimal = fn
740
:-(
%Decimal{} = d -> d
741
:-(
v when is_binary(v) -> Decimal.new(v)
742
:-(
v when is_integer(v) -> Decimal.new(v)
743
:-(
v when is_float(v) -> Decimal.from_float(v)
744
:-(
_ -> Decimal.new("0")
745 end
746
747
:-(
bAm = to_decimal.(foreign_amount)
748
749
:-(
fx_rate_val = cond do
750
:-(
is_map(fx_rate) and Map.has_key?(fx_rate, :fx_rate) -> fx_rate.fx_rate
751
:-(
is_map(fx_rate) and Map.has_key?(fx_rate, "fx_rate") -> fx_rate["fx_rate"]
752
:-(
true -> fx_rate
753 end
754
755
:-(
fxRate = to_decimal.(fx_rate_val)
756
757
:-(
fixed_fees = opts[:fixed_fees] || opts["fixed_fees"] || 0
758
:-(
fixed_fees_dec = to_decimal.(fixed_fees)
759
760
:-(
markup_pct = opts[:markup_pct] || opts["markup_pct"] || 0
761
:-(
markup_pct_dec = to_decimal.(markup_pct)
762
763 # converted = bAm * fxRate
764
:-(
converted = Decimal.mult(bAm, fxRate)
765
766 # add fixed fees (INR)
767
:-(
with_fees = Decimal.add(converted, fixed_fees_dec)
768
769 # apply markup: (1 + markupPct/100)
770
:-(
markup_multiplier = Decimal.add(Decimal.new("1"), Decimal.div(markup_pct_dec, Decimal.new("100")))
771
:-(
final = Decimal.mult(with_fees, markup_multiplier)
772
773 # round to 2 decimal places (UPI expects 2 decimals)
774
:-(
rounded = Decimal.round(final, 2)
775
776 {:ok, Decimal.to_string(rounded)}
777 rescue
778
:-(
_e -> {:error, "Invalid amount or fx_rate format"}
779 end
780 end
781
782 defp create_qr_record(params, fx_rate, inr_amount) do
783
:-(
qr_id = generate_qr_id(params["partner_id"], params["corridor"])
784
785
:-(
qr_data = %{
786 qr_id: qr_id,
787 partner_id: params["partner_id"],
788 merchant_id: params["merchant_id"],
789
:-(
merchant_name: params["merchant_name"] || "International Merchant",
790
:-(
merchant_category: params["merchant_category"] || "5411",
791 foreign_amount: params["amount"],
792 foreign_currency: params["currency"],
793 inr_amount: inr_amount,
794 fx_rate: fx_rate,
795 corridor: params["corridor"],
796
:-(
purpose_code: params["purpose_code"] || "P0101",
797
:-(
validity_minutes: params["validity_minutes"] || 300,
798
:-(
max_usage_count: params["max_usage_count"] || 1,
799 customer_ref: params["customer_ref"],
800
:-(
metadata: params["metadata"] || %{},
801 created_at: DateTime.utc_now()
802 }
803
804 {:ok, qr_data}
805 end
806
807 defp generate_qr_id(_partner_id, corridor) do
808
:-(
timestamp = DateTime.utc_now() |> DateTime.to_unix()
809
:-(
random = :rand.uniform(9999) |> Integer.to_string() |> String.pad_leading(4, "0")
810
:-(
corridor_code = String.upcase(String.slice(corridor, 0, 2))
811
:-(
"QR_#{corridor_code}_#{timestamp}_#{random}"
812 end
813
814 defp determine_initiation_mode(qr_data) do
815 # Determine initiation mode based on QR characteristics
816 # 01 = Static QR (merchant can reuse, amount can be entered by customer)
817 # 15 = Dynamic QR Code Offline (specific amount, single use, works offline)
818 # 16 = Dynamic QR (specific amount, single use, online validation)
819
820
:-(
metadata = qr_data.metadata || %{}
821
:-(
qr_type = Map.get(metadata, "qr_type")
822
:-(
initiation_mode = Map.get(metadata, "initiation_mode")
823
:-(
offline_mode = Map.get(metadata, "offline_mode", false)
824
825 # Debug logging
826 require Logger
827
:-(
Logger.debug("QR Mode Determination - metadata: #{inspect(metadata)}, qr_type: #{inspect(qr_type)}, initiation_mode: #{inspect(initiation_mode)}, offline_mode: #{inspect(offline_mode)}, amount: #{inspect(qr_data.foreign_amount)}, max_usage: #{inspect(qr_data.max_usage_count)}")
828
829
:-(
mode = cond do
830 # If metadata indicates a mandate request or explicit initiation_mode '17', prefer mandate mode
831
:-(
Map.get(metadata, "mandate") == true -> "17"
832
833
:-(
is_binary(Map.get(metadata, "mandate_type")) -> "17"
834
835
:-(
Map.get(metadata, "initiation_mode") == "17" -> "17"
836
837 # If metadata indicates online dynamic QR (mode 22) - real-time generation
838
:-(
Map.get(metadata, "online_dynamic") == true -> "22"
839
840
:-(
Map.get(metadata, "qr_source") in ["web", "api", "realtime"] -> "22"
841
842
:-(
Map.get(metadata, "initiation_mode") == "22" -> "22"
843
844 # Check if metadata explicitly specifies static QR
845
:-(
qr_type == "static" -> "01"
846
847 # Check if metadata has initiation_mode explicitly set
848
:-(
initiation_mode in ["01", "15", "16", "17", "22"] -> initiation_mode
849
850 # If offline mode is requested for dynamic QR
851
:-(
offline_mode == true and qr_data.foreign_amount not in ["0.00", "0", nil] -> "15"
852
853 # If multiple usage allowed, it's static
854
:-(
qr_data.max_usage_count > 1 -> "01"
855
856 # If amount is zero or nil, it's likely a static QR where customer enters amount
857
:-(
qr_data.foreign_amount in ["0.00", "0", nil] -> "01"
858
859 # Default to dynamic QR for specific amounts with single use
860
:-(
true -> "16"
861 end
862
863
:-(
Logger.debug("Determined initiation mode: #{mode}")
864
:-(
mode
865 end
866
867 defp build_upi_qr_string(qr_data) do
868 # Build UPI QR string according to NPCI UPI Global specification
869 # Use customer_ref from metadata if available, otherwise fallback to merchant_id
870
:-(
merchant_vpa = case qr_data.customer_ref do
871 nil ->
872
:-(
case get_in(qr_data.metadata, ["customer_ref"]) do
873
:-(
nil -> "#{qr_data.merchant_id}@mercury"
874
:-(
ref -> ref
875 end
876
:-(
ref when is_binary(ref) -> ref
877 end
878
879 # Current timestamp for QR generation
880
:-(
current_time = DateTime.utc_now()
881
:-(
qr_expire_time = DateTime.add(current_time, qr_data.validity_minutes * 60, :second)
882
883 # Determine initiation mode - 01 for static QR, 16 for dynamic QR
884
:-(
initiation_mode = determine_initiation_mode(qr_data)
885
886 # Build QR parameters according to NPCI UPI Linking Specifications v12
887 # Format INR amount for 'am' field (always 2 decimal digits)
888 # Per UPI spec: 'am' should always be the final debit amount in INR including all fees/markup
889
:-(
inr_amount =
890
:-(
case qr_data.inr_amount do
891
:-(
nil -> "0.00"
892 amt when is_binary(amt) ->
893
:-(
case Decimal.parse(amt) do
894
:-(
{dec, ""} -> Decimal.to_string(Decimal.round(dec, 2), :normal)
895
:-(
_ -> "0.00"
896 end
897 amt when is_number(amt) ->
898
:-(
:erlang.float_to_binary(amt, decimals: 2)
899
:-(
_ -> "0.00"
900 end
901
902 # Per UPI Linking Specifications v12:
903 # If 'am' is not present (static QR) or if 'mam' is populated and 'am' value is non-zero (dynamic QR),
904 # then 'am' field is editable. For static QRs (mode=01), amount can be editable (customer enters).
905 # For dynamic QRs (mode=16), amount is typically non-editable (fixed amount).
906
:-(
{am_field, mam_field} = determine_amount_fields(initiation_mode, inr_amount, qr_data)
907
908 require Logger
909
:-(
Logger.info("QR Amount Fields - Mode: #{initiation_mode}, am: #{am_field}, mam: #{mam_field}, INR: #{inr_amount}")
910
911 # Determine transaction/mandate reference (tr).
912 # For mandate QR (mode=17) prefer an explicit mandate/tr from metadata, otherwise generate MD prefixed id.
913
:-(
metadata = qr_data.metadata || %{}
914
:-(
tr_value = cond do
915
:-(
initiation_mode == "17" and Map.get(metadata, "tr") not in [nil, ""] -> Map.get(metadata, "tr")
916
:-(
initiation_mode == "17" -> "MD#{:crypto.strong_rand_bytes(12) |> Base.encode16() |> String.downcase()}"
917
:-(
true -> "MER#{:crypto.strong_rand_bytes(16) |> Base.encode16() |> String.downcase()}"
918 end
919
920 # Choose canonical invoice number: for mandate QRs prefer metadata-provided invoiceNo if present
921
:-(
invoice_no_meta = Map.get(metadata, "invoiceNo") || Map.get(metadata, "invoice_no")
922
:-(
invoice_no_canonical = if initiation_mode == "17" do
923
:-(
invoice_no_meta && String.replace(invoice_no_meta, "–", "-") || generate_invoice_number()
924 else
925
:-(
generate_invoice_number()
926 end
927
928
:-(
purpose_code_val = if initiation_mode == "17", do: "14", else: "11"
929
930
:-(
qr_params = [
931 "ver=01",
932
:-(
"mode=#{initiation_mode}",
933
:-(
"purpose=#{purpose_code_val}",
934
:-(
"orgid=#{get_org_id()}",
935
:-(
"tr=#{tr_value}",
936
:-(
"tn=MT_#{String.slice(qr_data.qr_id, -6, 6)}",
937
:-(
"pa=#{merchant_vpa}",
938
:-(
"pn=#{URI.encode(qr_data.merchant_name)}",
939
:-(
"mc=#{qr_data.merchant_category}",
940
:-(
"cu=#{if initiation_mode == "01", do: qr_data.foreign_currency, else: "INR"}",
941
:-(
"mid=#{sanitize_alphanumeric(qr_data.merchant_id)}",
942
:-(
"msid=#{get_merchant_store_id(qr_data)}",
943
:-(
"mtid=#{get_merchant_terminal_id(qr_data)}",
944
:-(
"mType=#{get_merchant_type(qr_data)}",
945
:-(
"mGr=#{get_merchant_genre(qr_data)}",
946
:-(
"mOnboarding=#{get_merchant_onboarding_type(qr_data)}",
947
:-(
"mLoc=#{get_merchant_location(qr_data)}",
948
:-(
"brand=#{URI.encode(get_merchant_brand(qr_data))}",
949
:-(
"cc=#{get_country_code(qr_data)}",
950
:-(
"bAm=#{qr_data.foreign_amount}",
951
:-(
"bCurr=#{qr_data.foreign_currency}",
952 am_field,
953
:-(
(if initiation_mode == "17" and mam_field, do: mam_field, else: nil),
954 "qrMedium=03",
955
:-(
"invoiceNo=#{URI.encode(invoice_no_canonical)}",
956
:-(
"invoiceDate=#{format_qr_timestamp(current_time)}",
957
:-(
"invoiceName=#{get_invoice_name(qr_data)}",
958
:-(
"QRexpire=#{format_qr_timestamp(qr_expire_time) |> URI.encode()}",
959
:-(
"QRts=#{format_qr_timestamp(current_time) |> URI.encode()}"
960 ]
961
:-(
|> Enum.reject(&is_nil/1) # Remove nil fields (like mam when not needed)
962
963 # If this is a mandate QR (mode=17) include mandate-specific fields
964
:-(
qr_params = if initiation_mode == "17" do
965 # For mandates, mandateStart/mandateEnd should be date-only values (YYYY-MM-DD)
966
:-(
mandate_start = fetch_mandate_date(qr_data, "mandateStart")
967
:-(
mandate_end = fetch_mandate_date(qr_data, "mandateEnd") || default_mandate_end()
968
:-(
mandate_freq = Map.get(metadata, "mandateFreq") || Map.get(metadata, "mandate_freq")
969
:-(
mandate_type = Map.get(metadata, "mandateType") || Map.get(metadata, "mandate_type")
970 # For mandate-specific parameters, don't re-add 'tr' or duplicate 'invoiceNo'
971
:-(
mandate_fields = []
972 |> add_if_present("mandateStart", mandate_start)
973 |> add_if_present("mandateEnd", mandate_end)
974 |> add_if_present("mandateFreq", mandate_freq)
975 |> add_if_present("mandateType", mandate_type)
976
977
:-(
qr_params ++ mandate_fields
978 else
979
:-(
qr_params
980 end
981
982 # Build QR string without signature first
983
:-(
base_scheme = if initiation_mode == "17", do: "upi://mandate?", else: "upiGlobal://pay?"
984
:-(
base_qr_string = base_scheme <> Enum.join(qr_params, "&")
985
986 # For mandate QR (mode=17) or online dynamic QR (mode=22) enforce presence of mandatory fields
987
:-(
if initiation_mode in ["17", "22"] do
988 # Required: orgid (handled), pa, pn, mc (non-zero)
989
:-(
cond do
990
:-(
merchant_vpa in [nil, ""] -> {:error, "#{initiation_mode} QR missing payee VPA (pa)"}
991
:-(
qr_data.merchant_name in [nil, ""] -> {:error, "#{initiation_mode} QR missing payee name (pn)"}
992
:-(
qr_data.merchant_category in [nil, "0", "0000"] -> {:error, "#{initiation_mode} QR missing or invalid merchant category (mc)"}
993
:-(
true ->
994 # Sign the QR and return; signing failure for secure/online QRs is an error
995
:-(
case Signature.sign_qr_string(base_qr_string) do
996 {:ok, signature_b64} when is_binary(signature_b64) ->
997
:-(
final_qr_string = base_qr_string <> "&sign=#{signature_b64}"
998 {:ok, final_qr_string}
999
:-(
{:error, reason} ->
1000
:-(
{:error, "#{initiation_mode} QR signing failed: #{inspect(reason)}"}
1001 end
1002 end
1003 else
1004 # Non-mandate QR: sign but fall back to dev signature if signing fails
1005
:-(
case Signature.sign_qr_string(base_qr_string) do
1006 {:ok, signature_b64} when is_binary(signature_b64) ->
1007
:-(
final_qr_string = base_qr_string <> "&sign=#{signature_b64}"
1008 {:ok, final_qr_string}
1009
1010 {:error, _reason} ->
1011 # If signing fails, generate signature placeholder for development
1012
:-(
signature = generate_dev_signature()
1013
:-(
final_qr_string = base_qr_string <> "&sign=#{signature}"
1014 {:ok, final_qr_string}
1015 end
1016 end
1017 end
1018
1019 # Helper functions for QR parameters
1020
:-(
defp get_org_id, do: "MER1010001" # NPCI assigned organization ID with 0001 suffix
1021
1022 defp get_merchant_store_id(qr_data) do
1023 # NPCI requirement: msid should be alphanumeric for consistency
1024
:-(
case get_in(qr_data.metadata, ["store_id"]) do
1025 nil ->
1026 # Generate alphanumeric store ID from partner_id and suffix
1027
:-(
clean_partner_id = sanitize_alphanumeric(qr_data.partner_id || "DEFAULT")
1028 #store_suffix = "001"
1029
:-(
"#{clean_partner_id}"
1030
1031 store_id ->
1032 # Ensure provided store_id is alphanumeric
1033
:-(
sanitize_alphanumeric(store_id)
1034 end
1035 end
1036
1037 defp get_merchant_terminal_id(qr_data) do
1038 # NPCI requirement: mtid MUST be present and alphanumeric
1039
:-(
case get_in(qr_data.metadata, ["terminal_id"]) do
1040 nil ->
1041 # Generate alphanumeric terminal ID from partner_id and suffix
1042
:-(
clean_partner_id = sanitize_alphanumeric(qr_data.partner_id || "DEFAULT")
1043
:-(
"#{clean_partner_id}"
1044
1045 terminal_id ->
1046 # Ensure provided terminal_id is alphanumeric
1047
:-(
sanitize_alphanumeric(terminal_id)
1048 end
1049 end
1050
1051
:-(
defp get_merchant_type(_qr_data), do: "SMALL"
1052
1053
:-(
defp get_merchant_genre(_qr_data), do: "OFFLINE"
1054
1055
:-(
defp get_merchant_onboarding_type(_qr_data), do: "AGGREGATOR"
1056
1057 defp get_merchant_location(qr_data) do
1058
:-(
case String.upcase(qr_data.corridor || "") do
1059
:-(
"SINGAPORE" -> "SG"
1060
:-(
"UAE" -> "AE"
1061
:-(
"USA" -> "US"
1062
:-(
_ -> "IN"
1063 end
1064 end
1065
1066 defp get_merchant_brand(qr_data) do
1067
:-(
get_in(qr_data.metadata, ["brand"]) || qr_data.merchant_name
1068 end
1069
1070 defp get_country_code(qr_data) do
1071
:-(
case String.upcase(qr_data.corridor || "") do
1072
:-(
"SINGAPORE" -> "SG"
1073
:-(
"UAE" -> "AE"
1074
:-(
"USA" -> "US"
1075
:-(
_ -> "IN"
1076 end
1077 end
1078
1079 defp get_invoice_name(qr_data) do
1080
:-(
get_in(qr_data.metadata, ["invoice_name"]) || qr_data.merchant_name
1081 end
1082 defp generate_invoice_number do
1083 # NPCI requirement: invoiceNo MUST be alphanumeric (max 20 chars)
1084 # Always use regular hyphen, never en dash
1085
:-(
timestamp = DateTime.utc_now() |> DateTime.to_unix() |> rem(999999)
1086
:-(
random = :crypto.strong_rand_bytes(4) |> Base.encode16() |> String.downcase()
1087
1088 # Format: INV + 6-digit timestamp + 8-char hex = 17 chars total (within 20 limit)
1089
:-(
invoice_no = "INV#{String.pad_leading(Integer.to_string(timestamp), 6, "0")}#{random}"
1090
1091 # Ensure alphanumeric only (remove any non-alphanumeric chars)
1092
:-(
sanitize_alphanumeric(invoice_no)
1093 end
1094
1095 # Helper to safely format mandate timestamps from metadata. Accepts either ISO strings or DateTime.
1096 defp fetch_mandate_timestamp(qr_data, key) do
1097 metadata = qr_data.metadata || %{}
1098 case Map.get(metadata, key) || Map.get(metadata, String.downcase(key)) do
1099 nil -> nil
1100 %DateTime{} = dt -> format_qr_timestamp(dt)
1101 s when is_binary(s) ->
1102 case DateTime.from_iso8601(s) do
1103 {:ok, dt, _} -> format_qr_timestamp(dt)
1104 _ -> s
1105 end
1106 other -> to_string(other)
1107 end
1108 end
1109
1110 # Fetch mandate date and return YYYY-MM-DD string. Accepts ISO strings or Date/DateTime.
1111 defp fetch_mandate_date(qr_data, key) do
1112
:-(
metadata = qr_data.metadata || %{}
1113
:-(
case Map.get(metadata, key) || Map.get(metadata, String.downcase(key)) do
1114
:-(
nil -> nil
1115
:-(
%DateTime{} = dt -> Date.to_iso8601(DateTime.to_date(dt))
1116
:-(
%Date{} = d -> Date.to_iso8601(d)
1117 s when is_binary(s) ->
1118 # Accept either full ISO datetime or date-only string
1119
:-(
case Date.from_iso8601(s) do
1120
:-(
{:ok, d} -> Date.to_iso8601(d)
1121 _ ->
1122
:-(
case DateTime.from_iso8601(s) do
1123
:-(
{:ok, dt, _} -> Date.to_iso8601(DateTime.to_date(dt))
1124
:-(
_ -> s
1125 end
1126 end
1127
:-(
other -> to_string(other)
1128 end
1129 end
1130
1131 defp default_mandate_end do
1132 Date.utc_today()
1133 |> Date.add(365)
1134
:-(
|> Date.to_iso8601()
1135 end
1136
1137
:-(
defp add_if_present(list, _key, nil), do: list
1138
:-(
defp add_if_present(list, _key, ""), do: list
1139 defp add_if_present(list, key, value) when is_binary(value) do
1140
:-(
list ++ ["#{key}=#{URI.encode(value)}"]
1141 end
1142
1143
1144 defp generate_dev_signature do
1145 # Generate a placeholder signature for development
1146
:-(
:crypto.strong_rand_bytes(256) |> Base.encode64()
1147 end
1148
1149 defp calculate_expiry(validity_minutes) do
1150 DateTime.utc_now()
1151 |> DateTime.add(validity_minutes * 60, :second)
1152
:-(
|> DateTime.to_iso8601()
1153 end
1154
1155 # Format timestamp for QR strings per NPCI specification
1156 # Format: "2024-11-15T20:40:27+05:30" (with timezone offset)
1157 defp format_qr_timestamp(datetime) do
1158 # Convert UTC datetime to Indian Standard Time (IST) which is UTC+05:30
1159 # Then format without fractional seconds to avoid microsecond parsing issues by some PSPs
1160
:-(
dt = datetime |> Timex.to_datetime("Asia/Kolkata")
1161
1162 # Truncate to seconds (remove fractional microseconds) and format with offset
1163 dt
1164 |> DateTime.truncate(:second)
1165
:-(
|> DateTime.to_iso8601()
1166 end # Determine amount fields per UPI Linking Specifications v12
1167 # Per spec: If 'am' is not present (static QR) or if 'mam' is populated and 'am' value is non-zero (dynamic QR),
1168 # then 'am' field is editable. 'am' should always be the final debit amount in INR.
1169 defp determine_amount_fields(initiation_mode, inr_amount, qr_data) do
1170
:-(
metadata = qr_data.metadata || %{}
1171
1172
:-(
is_zero_amount? = inr_amount in [nil, "0.00", "0"]
1173
1174
:-(
case initiation_mode do
1175 "01" ->
1176 # Static QR (mode=01): Amount is typically editable by customer.
1177 # Do NOT include 'mam' here per mandate-only rule. If a suggested amount is present,
1178 # include only 'am' so the customer can edit it; if no amount, return nils.
1179
:-(
if not is_zero_amount? do
1180
:-(
{"am=#{inr_amount}", nil}
1181 else
1182 {nil, nil}
1183 end
1184
1185 "15" ->
1186 # Dynamic QR Offline (mode=15): Amount is fixed/non-editable.
1187 # Include only 'am' (no 'mam').
1188
:-(
if is_zero_amount? do
1189 {nil, nil}
1190 else
1191
:-(
{"am=#{inr_amount}", nil}
1192 end
1193
1194 "16" ->
1195 # Dynamic QR (mode=16): Fixed amount, include only 'am' (no 'mam').
1196
:-(
if is_zero_amount? do
1197 {nil, nil}
1198 else
1199
:-(
{"am=#{inr_amount}", nil}
1200 end
1201
1202 "22" ->
1203 # Online dynamic QR (mode=22): Fixed amount, include only 'am' (no 'mam').
1204
:-(
if is_zero_amount? do
1205 {nil, nil}
1206 else
1207
:-(
{"am=#{inr_amount}", nil}
1208 end
1209 "17" ->
1210 # Mandate QR (mode=17): Mandate Amount (mam) is relevant.
1211 # Ensure we have sensible defaults for comparison
1212
:-(
max_amount = get_in(metadata, ["max_amount"]) || inr_amount || "0.00"
1213
1214 # If max_amount is greater than OR EQUAL TO inr_amount include both am and mam, else only am.
1215
:-(
to_decimal = fn
1216
:-(
%Decimal{} = d -> d
1217
:-(
nil -> Decimal.new("0.00")
1218 v when is_binary(v) ->
1219
:-(
case Decimal.parse(v) do
1220
:-(
{dec, ""} -> dec
1221
:-(
_ -> Decimal.new("0.00")
1222 end
1223
:-(
v when is_integer(v) -> Decimal.new(v)
1224
:-(
v when is_float(v) -> Decimal.from_float(v)
1225
:-(
_ -> Decimal.new("0.00")
1226 end
1227
1228
:-(
dec_max = to_decimal.(max_amount)
1229
:-(
dec_inr = to_decimal.(inr_amount || "0.00")
1230
1231
:-(
case Decimal.compare(dec_max, dec_inr) do
1232
:-(
:gt -> {"am=#{inr_amount}", "mam=#{max_amount}"}
1233
:-(
:eq -> {"am=#{inr_amount}", "mam=#{max_amount}"}
1234
:-(
_ -> {"am=#{inr_amount}", nil}
1235 end
1236
1237
1238 _ ->
1239 # Unknown mode: be conservative and include only am if present
1240
:-(
if not is_zero_amount? do
1241
:-(
{"am=#{inr_amount}", nil}
1242 else
1243 {nil, nil}
1244 end
1245 end
1246 end
1247
1248 # Helper function to sanitize strings to be alphanumeric only
1249 # NPCI requirement: key fields like mid, msid, mtid must be alphanumeric
1250 defp sanitize_alphanumeric(value) when is_binary(value) do
1251
:-(
String.replace(value, ~r/[^A-Za-z0-9]/, "")
1252 end
1253
1254
:-(
defp sanitize_alphanumeric(_value) when is_nil(_value), do: "DEFAULT"
1255
1256
:-(
defp sanitize_alphanumeric(_value), do: "DEFAULT"
1257 end
Line Hits Source