cover/Elixir.DaProductApp.Transactions.Transaction.html

1 defmodule DaProductApp.Transactions.Transaction do
2 @moduledoc """
3 Transaction aggregate for UPI International payment orchestration.
4
5 Represents the flow:
6 NPCI debits customer → Credits your PSP → PSP converts currency → Credits international partner → Partner credits merchant
7 """
8 use Ecto.Schema
9 import Ecto.Changeset
10 import Ecto.Query
11 alias Decimal, as: D
12
13 alias DaProductApp.Transactions.TransactionEvent
14 alias DaProductApp.Partners.{Partner, Merchant}
15
16 @states ~w(
17 initiated npci_received debit_secured credit_pending partner_credit_initiated
18 credit_success chktxn_pending reversal_pending success failure deemed reversed
19 )
20 @statuses ~w(pending processing success failure deemed reversed)
21 @transaction_types ~w(DOMESTIC INTERNATIONAL)
22 @precision 2
23
24 @primary_key {:id, :id, autogenerate: true}
25 @foreign_key_type :id
26
27 143 schema "transactions" do
28 field :org_txn_id, :string
29 field :parent_qr_validation_id, :id
30
31 # Customer details (from NPCI ReqPay)
32 field :payer_addr, :string # Customer UPI ID
33 field :payer_name, :string # Customer name
34 field :payee_addr, :string # Merchant VPA
35 field :payee_name, :string # Merchant name
36 field :payee_mid, :string # Merchant ID
37 field :payer_bank_ref, :string # Customer bank reference
38
39 # INR transaction details (NPCI side)
40 field :inr_amount, :decimal # INR amount debited from customer
41 field :currency, :string, default: "INR"
42
43 # Foreign currency details (Partner side)
44 field :foreign_amount, :decimal # Amount in merchant's local currency
45 field :foreign_currency, :string # Merchant's currency (SGD, USD, AED)
46 field :fx_rate, :decimal # Applied exchange rate
47 field :markup_rate, :decimal # PSP markup percentage (renamed from markup_pct)
48 field :markup_pct, :decimal # Legacy field for backward compatibility
49
50 # International specific fields
51 field :corridor, :string # "SINGAPORE", "UAE", "USA"
52 field :transaction_type, :string, default: "INTERNATIONAL"
53 field :fx_provider, :string # "RBI", "PARTNER_API", "MANUAL"
54 field :fx_locked_at, :utc_datetime # When FX rate was locked
55
56 # UPI protocol fields
57 field :ver_token, :string
58 field :network_inst_id, :string
59 field :req_msg_id, :string # NPCI request message ID
60 field :resp_msg_id, :string # Response message ID
61
62 # Transaction state tracking
63 field :current_state, :string, default: "initiated"
64 field :status, :string, default: "pending"
65
66 # Timestamps for flow stages
67 field :npci_received_at, :utc_datetime # When NPCI credited your PSP
68 field :debit_secured_at, :utc_datetime # Legacy - customer already debited by NPCI
69 field :credit_requested_at, :utc_datetime # When partner credit was requested
70 field :partner_credited_at, :utc_datetime # When partner confirmed credit
71 field :credit_completed_at, :utc_datetime # When merchant received funds
72 field :chktxn_requested_at, :utc_datetime
73 field :chktxn_ref_id, :string, source: :chk_ref_id # RefId from ReqChkTxn request, maps to chk_ref_id column
74 field :reversal_requested_at, :utc_datetime
75 field :completed_at, :utc_datetime # Final completion
76 field :finalized_at, :utc_datetime # Legacy field
77
78 # Error and failure handling
79 field :failure_code, :string
80 field :failure_reason, :string
81 field :deemed, :boolean, default: false
82 field :partner_txn_id, :string # Partner's transaction reference
83 field :npci_reversal_ref, :string # NPCI reversal reference
84
85 # Relationships
86 belongs_to :partner, Partner
87 belongs_to :merchant, Merchant
88 has_many :events, TransactionEvent
89
90 timestamps(type: :utc_datetime)
91 end
92
93 @required ~w(org_txn_id current_state status transaction_type)a
94 @optional ~w(
95 parent_qr_validation_id payer_addr payer_name payee_addr payee_name payee_mid
96 payer_bank_ref inr_amount currency foreign_amount foreign_currency fx_rate
97 markup_rate markup_pct corridor fx_provider fx_locked_at ver_token network_inst_id
98 req_msg_id resp_msg_id npci_received_at debit_secured_at credit_requested_at
99 partner_credited_at credit_completed_at chktxn_requested_at chktxn_ref_id
100 reversal_requested_at completed_at finalized_at failure_code failure_reason
101 deemed partner_txn_id npci_reversal_ref partner_id merchant_id
102 )a
103
104 def changeset(struct, attrs) do
105 struct
106 |> cast(attrs, @required ++ @optional)
107 |> generate_org_txn_id()
108 |> validate_required(@required)
109 |> validate_inclusion(:current_state, @states)
110 |> validate_inclusion(:status, @statuses)
111 |> validate_inclusion(:transaction_type, @transaction_types)
112 |> validate_international_fields()
113 |> validate_amounts()
114 |> unique_constraint(:org_txn_id)
115 7 |> unique_constraint(:ver_token)
116 end
117
118 @doc """
119 Changeset for UPI International transactions with FX validation
120 """
121 def international_changeset(struct, attrs) do
122
:-(
attrs_with_type = Map.put(attrs, :transaction_type, "INTERNATIONAL")
123
124 struct
125 |> changeset(attrs_with_type)
126 |> validate_required([:foreign_amount, :foreign_currency, :corridor, :fx_rate])
127
:-(
|> validate_fx_calculation()
128 end
129
130 @doc """
131 Changeset for domestic UPI transactions
132 """
133 def domestic_changeset(struct, attrs) do
134
:-(
attrs_with_type = Map.put(attrs, :transaction_type, "DOMESTIC")
135
136 struct
137 |> changeset(attrs_with_type)
138 |> put_change(:foreign_currency, "INR")
139
:-(
|> put_change(:corridor, nil)
140 end
141
142 # Private validation functions
143
144 defp validate_international_fields(changeset) do
145 7 case get_field(changeset, :transaction_type) do
146 "INTERNATIONAL" ->
147 changeset
148 |> validate_required([:foreign_currency, :corridor])
149 |> validate_inclusion(:corridor, ["SINGAPORE", "UAE", "USA", "UK", "JAPAN", "AUSTRALIA"])
150 |> validate_inclusion(:foreign_currency, ["USD", "SGD", "AED", "GBP", "JPY", "AUD"])
151 7 |> validate_fx_fields_consistency()
152
153 "DOMESTIC" ->
154 changeset
155 |> put_change(:foreign_currency, "INR")
156
:-(
|> put_change(:corridor, nil)
157
158 _ ->
159
:-(
changeset
160 end
161 end
162
163 defp validate_amounts(changeset) do
164 changeset
165 |> validate_number(:inr_amount, greater_than: 0)
166 |> validate_number(:foreign_amount, greater_than: 0)
167 |> validate_number(:fx_rate, greater_than: 0)
168 |> validate_number(:markup_rate, greater_than_or_equal_to: 0)
169 7 |> validate_number(:markup_pct, greater_than_or_equal_to: 0)
170 end
171
172 defp validate_fx_fields_consistency(changeset) do
173 # Sync markup_rate and markup_pct for backward compatibility
174 7 markup_rate = get_field(changeset, :markup_rate)
175 7 markup_pct = get_field(changeset, :markup_pct)
176
177 7 cond do
178 7 markup_rate && !markup_pct ->
179
:-(
put_change(changeset, :markup_pct, markup_rate)
180
181 7 markup_pct && !markup_rate ->
182
:-(
put_change(changeset, :markup_rate, markup_pct)
183
184 7 true ->
185 7 changeset
186 end
187 end
188
189 defp validate_fx_calculation(changeset) do
190
:-(
inr_amount = get_field(changeset, :inr_amount)
191
:-(
foreign_amount = get_field(changeset, :foreign_amount)
192
:-(
fx_rate = get_field(changeset, :fx_rate)
193
:-(
markup_rate = get_field(changeset, :markup_rate) || Decimal.new("0")
194
195
:-(
if inr_amount && foreign_amount && fx_rate do
196 # Validate: Foreign Amount = INR Amount × FX Rate ÷ (1 + Markup%)
197
:-(
markup_multiplier = Decimal.add(1, Decimal.div(markup_rate, 100))
198
:-(
expected_foreign = Decimal.div(inr_amount, markup_multiplier) |> Decimal.mult(fx_rate)
199
:-(
amount_diff = Decimal.sub(foreign_amount, expected_foreign) |> Decimal.abs()
200
201
:-(
if Decimal.gt?(amount_diff, Decimal.new("0.01")) do # Allow 1 cent tolerance
202
:-(
add_error(changeset, :foreign_amount, "does not match FX calculation")
203 else
204
:-(
changeset
205 end
206 else
207
:-(
changeset
208 end
209 end
210
211 defp generate_org_txn_id(changeset) do
212 7 case get_field(changeset, :org_txn_id) do
213 nil ->
214
:-(
txn_id = "UPI_INT_" <>
215 (DateTime.utc_now()
216 |> DateTime.to_string()
217 |> String.replace(~r/[^\d]/, "")
218 |> String.slice(0, 12))
219
:-(
put_change(changeset, :org_txn_id, txn_id)
220
221 7 _ -> changeset
222 end
223 end
224
225 @doc """
226 Get transaction by org_txn_id with events preloaded
227 """
228 def get_with_events(org_txn_id) do
229
:-(
events_query = from(e in TransactionEvent, order_by: [asc: e.seq])
230
231
:-(
from(t in __MODULE__,
232 where: t.org_txn_id == ^org_txn_id,
233 preload: [events: ^events_query]
234 )
235 end
236
237 @doc """
238 Check if transaction is in a final state
239 """
240 def final_state?(transaction) do
241
:-(
transaction.current_state in ["success", "failure", "deemed", "reversed"]
242 end
243
244 @doc """
245 Check if transaction can be reversed
246 """
247 def reversible?(transaction) do
248
:-(
transaction.current_state in ["credit_pending", "partner_credit_initiated"] and
249
:-(
not final_state?(transaction)
250 end
251
252 @doc """
253 Calculate total INR amount including markup
254 """
255 def calculate_total_inr_with_markup(foreign_amount, fx_rate, markup_rate) do
256 # INR = Foreign Amount ÷ FX Rate × (1 + Markup%)
257
:-(
markup_multiplier = Decimal.add(1, Decimal.div(markup_rate, 100))
258
:-(
Decimal.div(foreign_amount, fx_rate) |> Decimal.mult(markup_multiplier)
259 end
260
261 # --- Private Functions ---
262
263 # --- helpers ---
264
265 defp validate_positive_decimal(:foreign_amount, nil), do: []
266 defp validate_positive_decimal(:fx_rate, nil), do: []
267 defp validate_positive_decimal(field, value) do
268 case positive_decimal?(value) do
269 true -> []
270 false -> [{field, "must be > 0"}]
271 end
272 end
273
274 defp validate_non_negative_decimal(:markup_pct, nil), do: []
275 defp validate_non_negative_decimal(field, value) do
276 case non_negative_decimal?(value) do
277 true -> []
278 false -> [{field, "must be >= 0"}]
279 end
280 end
281
282 defp positive_decimal?(%D{}=d), do: D.compare(d, D.new(0)) == :gt
283 defp positive_decimal?(n) when is_number(n), do: n > 0
284 defp positive_decimal?(_), do: false
285
286 defp non_negative_decimal?(%D{}=d), do: D.compare(d, D.new(0)) in [:gt, :eq]
287 defp non_negative_decimal?(n) when is_number(n), do: n >= 0
288 defp non_negative_decimal?(_), do: false
289
290 defp compute_inr_amount(changeset) do
291 inr_amount = get_field(changeset, :inr_amount)
292 fa = get_field(changeset, :foreign_amount)
293 fx = get_field(changeset, :fx_rate)
294 markup = get_field(changeset, :markup_pct)
295 cond do
296 inr_amount -> changeset
297 is_nil(fa) or is_nil(fx) -> changeset
298 true ->
299 base = to_decimal(fa) |> D.mult(to_decimal(fx))
300 with_markup = if markup, do: apply_markup(base, to_decimal(markup)), else: base
301 put_change(changeset, :inr_amount, D.round(with_markup, @precision))
302 end
303 end
304
305 defp apply_markup(amount, markup_pct_dec) do
306 # markup_pct is percentage (e.g. 1.5 means +1.5%)
307 factor = D.add(D.new(1), D.div(markup_pct_dec, D.new(100)))
308 D.mult(amount, factor)
309 end
310
311 defp to_decimal(%D{}=d), do: d
312 defp to_decimal(n) when is_integer(n), do: D.new(n)
313 defp to_decimal(n) when is_float(n), do: n |> :erlang.float_to_binary(decimals: 10) |> D.new()
314 defp to_decimal(<<_::binary>>=b), do: D.new(b)
315 defp to_decimal(_), do: D.new(0)
316
317 defp prevent_org_txn_id_change(changeset, %{id: nil}), do: changeset
318 defp prevent_org_txn_id_change(changeset, _struct) do
319 if changed?(changeset, :org_txn_id) do
320 add_error(changeset, :org_txn_id, "cannot be changed")
321 else
322 changeset
323 end
324 end
325 end
Line Hits Source