cover/Elixir.DaProductApp.QRValidation.QRValidation.html

1 defmodule DaProductApp.QRValidation.QRValidation do
2 @moduledoc """
3 QR Validation aggregate for UPI International transactions.
4
5 Stores normalized data from ReqValQr/RespValQr with FX information.
6 Handles both domestic and international QR validation flows.
7 """
8 use Ecto.Schema
9 import Ecto.Changeset
10
11 alias DaProductApp.QRValidation.{QRValidationEvent, FXQuote}
12 alias DaProductApp.Partners.{Partner, Merchant}
13
14 # Temporarily use integer ID to match database schema
15 # @primary_key {:id, :binary_id, autogenerate: true}
16 # @foreign_key_type :binary_id
17
18 53 schema "qr_validations" do
19 # Core UPI fields
20 field :txn_id, :string
21 field :msg_id, :string
22 field :org_id, :string
23 field :payer_addr, :string
24 field :payer_name, :string
25 field :payee_addr, :string # Enhanced: Merchant VPA
26 field :payee_name, :string # Enhanced: Merchant name
27 field :network_inst_id, :string
28 field :con_code, :string
29 field :qr_version, :string
30 field :qr_medium, :string
31 field :ver_token, :string
32 field :qr_expires_at, :utc_datetime
33
34 # International FX fields (enhanced)
35 field :base_amount, :decimal # Amount in merchant's local currency
36 field :base_currency, :string # Merchant's currency (USD, SGD, AED)
37 field :foreign_amount, :decimal # Legacy field, same as base_amount
38 field :foreign_currency, :string # Legacy field, same as base_currency
39 field :fx_rate, :decimal # Applied exchange rate
40 field :markup_pct, :decimal # PSP markup percentage
41 field :inr_amount_calc, :decimal # Calculated INR amount for customer
42 field :fx_timestamp, :utc_datetime # When FX rate was locked
43 field :corridor, :string # "SINGAPORE", "UAE", "USA"
44 field :fx_provider, :string # "RBI", "PARTNER_API", "MANUAL"
45
46 # NPCI Communication timestamps
47 field :npci_request_received_at, :utc_datetime # When NPCI request was received by PSP
48 field :npci_response_sent_at, :utc_datetime # When PSP response was sent back to NPCI
49
50 # QR and validation metadata
51 field :qr_hash, :binary
52 field :raw_xml, :binary
53 field :resp_xml, :binary # SHA-256 hash of RespValQr XML sent to NPCI
54 field :status, :string, default: "validated"
55 field :validation_type, :string, default: "INTERNATIONAL" # "DOMESTIC", "INTERNATIONAL"
56 field :merchant_category, :string # Business category
57 field :transaction_purpose, :string # Purpose code for international
58 field :initiation_mode, :string # "01" for static, "16" for dynamic QR
59
60 # Relationships
61 belongs_to :partner, Partner
62 belongs_to :merchant, Merchant
63 has_many :events, QRValidationEvent
64 has_many :fx_quotes, FXQuote
65
66 timestamps(type: :utc_datetime)
67 end
68
69 @required ~w(msg_id org_id status validation_type)a
70 @optional ~w(
71 txn_id payer_addr payer_name payee_addr payee_name network_inst_id
72 con_code qr_version qr_medium ver_token qr_expires_at
73 base_amount base_currency foreign_amount foreign_currency
74 fx_rate markup_pct inr_amount_calc fx_timestamp corridor fx_provider
75 npci_request_received_at npci_response_sent_at
76 qr_hash raw_xml resp_xml merchant_category transaction_purpose initiation_mode
77 partner_id merchant_id
78 )a
79
80 def changeset(struct, attrs) do
81 struct
82 |> cast(attrs, @required ++ @optional)
83 |> validate_required(@required)
84 |> validate_inclusion(:validation_type, ["DOMESTIC", "INTERNATIONAL"])
85 |> validate_inclusion(:corridor, ["SINGAPORE", "UAE", "USA", "UK", "JAPAN", "AUSTRALIA"])
86 |> validate_inclusion(:base_currency, ["USD", "SGD", "AED", "GBP", "JPY", "AUD", "INR"])
87 |> validate_number(:fx_rate, greater_than: 0)
88 |> validate_number(:markup_pct, greater_than_or_equal_to: 0)
89 |> validate_base_amount_for_qr_type()
90 |> validate_international_fields()
91 |> unique_constraint([:msg_id, :org_id])
92 5 |> unique_constraint(:ver_token)
93 end
94
95 @doc """
96 Changeset for international QR validation with FX details
97 """
98 def international_changeset(struct, attrs) do
99
:-(
attrs_with_type = Map.put(attrs, :validation_type, "INTERNATIONAL")
100
101 struct
102 |> changeset(attrs_with_type)
103 |> validate_required([:base_amount, :base_currency, :corridor, :fx_rate])
104
:-(
|> validate_fx_calculation()
105 end
106
107 @doc """
108 Changeset for domestic QR validation
109 """
110 def domestic_changeset(struct, attrs) do
111
:-(
attrs_with_type = Map.put(attrs, :validation_type, "DOMESTIC")
112
113 struct
114 |> changeset(attrs_with_type)
115 |> put_change(:base_currency, "INR")
116
:-(
|> put_change(:corridor, nil)
117 end
118
119 # Private validation functions
120
121 defp validate_international_fields(changeset) do
122 5 case get_field(changeset, :validation_type) do
123 "INTERNATIONAL" ->
124 changeset
125 |> validate_required([:base_amount, :base_currency, :corridor])
126 5 |> validate_fx_fields_consistency()
127
128 "DOMESTIC" ->
129 changeset
130 |> put_change(:base_currency, "INR")
131
:-(
|> put_change(:corridor, nil)
132 end
133 end
134
135 defp validate_fx_fields_consistency(changeset) do
136 # Ensure foreign_amount matches base_amount (legacy compatibility)
137 5 base_amount = get_field(changeset, :base_amount)
138 5 base_currency = get_field(changeset, :base_currency)
139
140 changeset
141 |> put_change(:foreign_amount, base_amount)
142 5 |> put_change(:foreign_currency, base_currency)
143 end
144
145 defp validate_fx_calculation(changeset) do
146
:-(
base_amount = get_field(changeset, :base_amount)
147
:-(
fx_rate = get_field(changeset, :fx_rate)
148
:-(
markup_pct = get_field(changeset, :markup_pct) || Decimal.new("0")
149
:-(
inr_calc = get_field(changeset, :inr_amount_calc)
150
151
:-(
if base_amount && fx_rate && inr_calc do
152 # Validate INR calculation: INR = Base Amount ÷ FX Rate × (1 + Markup)
153
:-(
expected_inr = calculate_inr_amount(base_amount, fx_rate, markup_pct)
154
:-(
inr_diff = Decimal.sub(inr_calc, expected_inr) |> Decimal.abs()
155
156
:-(
if Decimal.gt?(inr_diff, Decimal.new("0.01")) do # Allow 1 paisa tolerance
157
:-(
add_error(changeset, :inr_amount_calc, "does not match FX calculation")
158 else
159
:-(
changeset
160 end
161 else
162
:-(
changeset
163 end
164 end
165
166 defp validate_base_amount_for_qr_type(changeset) do
167 5 initiation_mode = get_field(changeset, :initiation_mode)
168 5 base_amount = get_field(changeset, :base_amount)
169
170 5 case {initiation_mode, base_amount} do
171 # Static QR (mode "01") can have 0.00 amount (customer enters amount)
172 {"01", amount} when is_nil(amount) ->
173
:-(
changeset
174
175 {"01", amount} when not is_nil(amount) ->
176 # For static QR, check if amount is zero (allow 0.00 for static QRs)
177
:-(
if Decimal.equal?(amount, Decimal.new("0")) or Decimal.equal?(amount, Decimal.new("0.00")) do
178
:-(
changeset
179 else
180
:-(
validate_number(changeset, :base_amount, greater_than_or_equal_to: 0)
181 end
182
183 # Dynamic QR (mode "16") must have amount > 0
184 {"16", amount} when not is_nil(amount) ->
185
:-(
validate_number(changeset, :base_amount, greater_than: 0)
186
187 # If no initiation_mode specified, default to requiring amount > 0 (backward compatibility)
188 {nil, amount} when not is_nil(amount) ->
189
:-(
validate_number(changeset, :base_amount, greater_than: 0)
190
191 # Allow nil amount for now (will be caught by other validations if required)
192 {_, nil} ->
193 5 changeset
194
195 # Any other case, validate normally
196 _ ->
197
:-(
validate_number(changeset, :base_amount, greater_than: 0)
198 end
199 end
200
201 defp calculate_inr_amount(base_amount, fx_rate, markup_pct) do
202 # INR = Base Amount ÷ FX Rate × (1 + Markup%)
203
:-(
inr_before_markup = Decimal.div(base_amount, fx_rate)
204
:-(
markup_multiplier = Decimal.add(1, Decimal.div(markup_pct, 100))
205
:-(
Decimal.mult(inr_before_markup, markup_multiplier)
206 end
207 end
Line Hits Source