| 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 |