defmodule DaProductApp.Transactions.Transaction do @moduledoc """ Transaction aggregate for UPI International payment orchestration. Represents the flow: NPCI debits customer → Credits your PSP → PSP converts currency → Credits international partner → Partner credits merchant """ use Ecto.Schema import Ecto.Changeset import Ecto.Query alias Decimal, as: D alias DaProductApp.Transactions.TransactionEvent alias DaProductApp.Partners.{Partner, Merchant} @states ~w( initiated npci_received debit_secured credit_pending partner_credit_initiated credit_success chktxn_pending reversal_pending success failure deemed reversed ) @statuses ~w(pending processing success failure deemed reversed) @transaction_types ~w(DOMESTIC INTERNATIONAL) @precision 2 @primary_key {:id, :id, autogenerate: true} @foreign_key_type :id schema "transactions" do field :org_txn_id, :string field :parent_qr_validation_id, :id # Customer details (from NPCI ReqPay) field :payer_addr, :string # Customer UPI ID field :payer_name, :string # Customer name field :payee_addr, :string # Merchant VPA field :payee_name, :string # Merchant name field :payee_mid, :string # Merchant ID field :payer_bank_ref, :string # Customer bank reference # INR transaction details (NPCI side) field :inr_amount, :decimal # INR amount debited from customer field :currency, :string, default: "INR" # Foreign currency details (Partner side) field :foreign_amount, :decimal # Amount in merchant's local currency field :foreign_currency, :string # Merchant's currency (SGD, USD, AED) field :fx_rate, :decimal # Applied exchange rate field :markup_rate, :decimal # PSP markup percentage (renamed from markup_pct) field :markup_pct, :decimal # Legacy field for backward compatibility # International specific fields field :corridor, :string # "SINGAPORE", "UAE", "USA" field :transaction_type, :string, default: "INTERNATIONAL" field :fx_provider, :string # "RBI", "PARTNER_API", "MANUAL" field :fx_locked_at, :utc_datetime # When FX rate was locked # UPI protocol fields field :ver_token, :string field :network_inst_id, :string field :req_msg_id, :string # NPCI request message ID field :resp_msg_id, :string # Response message ID # Transaction state tracking field :current_state, :string, default: "initiated" field :status, :string, default: "pending" # Timestamps for flow stages field :npci_received_at, :utc_datetime # When NPCI credited your PSP field :debit_secured_at, :utc_datetime # Legacy - customer already debited by NPCI field :credit_requested_at, :utc_datetime # When partner credit was requested field :partner_credited_at, :utc_datetime # When partner confirmed credit field :credit_completed_at, :utc_datetime # When merchant received funds field :chktxn_requested_at, :utc_datetime field :chktxn_ref_id, :string, source: :chk_ref_id # RefId from ReqChkTxn request, maps to chk_ref_id column field :reversal_requested_at, :utc_datetime field :completed_at, :utc_datetime # Final completion field :finalized_at, :utc_datetime # Legacy field # Error and failure handling field :failure_code, :string field :failure_reason, :string field :deemed, :boolean, default: false field :partner_txn_id, :string # Partner's transaction reference field :npci_reversal_ref, :string # NPCI reversal reference # Relationships belongs_to :partner, Partner belongs_to :merchant, Merchant has_many :events, TransactionEvent timestamps(type: :utc_datetime) end @required ~w(org_txn_id current_state status transaction_type)a @optional ~w( parent_qr_validation_id payer_addr payer_name payee_addr payee_name payee_mid payer_bank_ref inr_amount currency foreign_amount foreign_currency fx_rate markup_rate markup_pct corridor fx_provider fx_locked_at ver_token network_inst_id req_msg_id resp_msg_id npci_received_at debit_secured_at credit_requested_at partner_credited_at credit_completed_at chktxn_requested_at chktxn_ref_id reversal_requested_at completed_at finalized_at failure_code failure_reason deemed partner_txn_id npci_reversal_ref partner_id merchant_id )a def changeset(struct, attrs) do struct |> cast(attrs, @required ++ @optional) |> generate_org_txn_id() |> validate_required(@required) |> validate_inclusion(:current_state, @states) |> validate_inclusion(:status, @statuses) |> validate_inclusion(:transaction_type, @transaction_types) |> validate_international_fields() |> validate_amounts() |> unique_constraint(:org_txn_id) |> unique_constraint(:ver_token) end @doc """ Changeset for UPI International transactions with FX validation """ def international_changeset(struct, attrs) do attrs_with_type = Map.put(attrs, :transaction_type, "INTERNATIONAL") struct |> changeset(attrs_with_type) |> validate_required([:foreign_amount, :foreign_currency, :corridor, :fx_rate]) |> validate_fx_calculation() end @doc """ Changeset for domestic UPI transactions """ def domestic_changeset(struct, attrs) do attrs_with_type = Map.put(attrs, :transaction_type, "DOMESTIC") struct |> changeset(attrs_with_type) |> put_change(:foreign_currency, "INR") |> put_change(:corridor, nil) end # Private validation functions defp validate_international_fields(changeset) do case get_field(changeset, :transaction_type) do "INTERNATIONAL" -> changeset |> validate_required([:foreign_currency, :corridor]) |> validate_inclusion(:corridor, ["SINGAPORE", "UAE", "USA", "UK", "JAPAN", "AUSTRALIA"]) |> validate_inclusion(:foreign_currency, ["USD", "SGD", "AED", "GBP", "JPY", "AUD"]) |> validate_fx_fields_consistency() "DOMESTIC" -> changeset |> put_change(:foreign_currency, "INR") |> put_change(:corridor, nil) _ -> changeset end end defp validate_amounts(changeset) do changeset |> validate_number(:inr_amount, greater_than_or_equal_to: 0) |> validate_number(:foreign_amount, greater_than: 0) |> validate_number(:fx_rate, greater_than: 0) |> validate_number(:markup_rate, greater_than_or_equal_to: 0) |> validate_number(:markup_pct, greater_than_or_equal_to: 0) end defp validate_fx_fields_consistency(changeset) do # Sync markup_rate and markup_pct for backward compatibility markup_rate = get_field(changeset, :markup_rate) markup_pct = get_field(changeset, :markup_pct) cond do markup_rate && !markup_pct -> put_change(changeset, :markup_pct, markup_rate) markup_pct && !markup_rate -> put_change(changeset, :markup_rate, markup_pct) true -> changeset end end defp validate_fx_calculation(changeset) do inr_amount = get_field(changeset, :inr_amount) foreign_amount = get_field(changeset, :foreign_amount) fx_rate = get_field(changeset, :fx_rate) markup_rate = get_field(changeset, :markup_rate) || Decimal.new("0") if inr_amount && foreign_amount && fx_rate do # Validate: Foreign Amount = INR Amount × FX Rate ÷ (1 + Markup%) markup_multiplier = Decimal.add(1, Decimal.div(markup_rate, 100)) expected_foreign = Decimal.div(inr_amount, markup_multiplier) |> Decimal.mult(fx_rate) amount_diff = Decimal.sub(foreign_amount, expected_foreign) |> Decimal.abs() if Decimal.gt?(amount_diff, Decimal.new("0.01")) do # Allow 1 cent tolerance add_error(changeset, :foreign_amount, "does not match FX calculation") else changeset end else changeset end end defp generate_org_txn_id(changeset) do case get_field(changeset, :org_txn_id) do nil -> txn_id = "UPI_INT_" <> (DateTime.utc_now() |> DateTime.to_string() |> String.replace(~r/[^\d]/, "") |> String.slice(0, 12)) put_change(changeset, :org_txn_id, txn_id) _ -> changeset end end @doc """ Get transaction by org_txn_id with events preloaded """ def get_with_events(org_txn_id) do events_query = from(e in TransactionEvent, order_by: [asc: e.seq]) from(t in __MODULE__, where: t.org_txn_id == ^org_txn_id, preload: [events: ^events_query] ) end @doc """ Check if transaction is in a final state """ def final_state?(transaction) do transaction.current_state in ["success", "failure", "deemed", "reversed"] end @doc """ Check if transaction can be reversed """ def reversible?(transaction) do transaction.current_state in ["credit_pending", "partner_credit_initiated"] and not final_state?(transaction) end @doc """ Calculate total INR amount including markup """ def calculate_total_inr_with_markup(foreign_amount, fx_rate, markup_rate) do # INR = Foreign Amount ÷ FX Rate × (1 + Markup%) markup_multiplier = Decimal.add(1, Decimal.div(markup_rate, 100)) Decimal.div(foreign_amount, fx_rate) |> Decimal.mult(markup_multiplier) end # --- Private Functions --- # --- helpers --- defp validate_positive_decimal(:foreign_amount, nil), do: [] defp validate_positive_decimal(:fx_rate, nil), do: [] defp validate_positive_decimal(field, value) do case positive_decimal?(value) do true -> [] false -> [{field, "must be > 0"}] end end defp validate_non_negative_decimal(:markup_pct, nil), do: [] defp validate_non_negative_decimal(field, value) do case non_negative_decimal?(value) do true -> [] false -> [{field, "must be >= 0"}] end end defp positive_decimal?(%D{}=d), do: D.compare(d, D.new(0)) == :gt defp positive_decimal?(n) when is_number(n), do: n > 0 defp positive_decimal?(_), do: false defp non_negative_decimal?(%D{}=d), do: D.compare(d, D.new(0)) in [:gt, :eq] defp non_negative_decimal?(n) when is_number(n), do: n >= 0 defp non_negative_decimal?(_), do: false defp compute_inr_amount(changeset) do inr_amount = get_field(changeset, :inr_amount) fa = get_field(changeset, :foreign_amount) fx = get_field(changeset, :fx_rate) markup = get_field(changeset, :markup_pct) cond do inr_amount -> changeset is_nil(fa) or is_nil(fx) -> changeset true -> base = to_decimal(fa) |> D.mult(to_decimal(fx)) with_markup = if markup, do: apply_markup(base, to_decimal(markup)), else: base put_change(changeset, :inr_amount, D.round(with_markup, @precision)) end end defp apply_markup(amount, markup_pct_dec) do # markup_pct is percentage (e.g. 1.5 means +1.5%) factor = D.add(D.new(1), D.div(markup_pct_dec, D.new(100))) D.mult(amount, factor) end defp to_decimal(%D{}=d), do: d defp to_decimal(n) when is_integer(n), do: D.new(n) defp to_decimal(n) when is_float(n), do: n |> :erlang.float_to_binary(decimals: 10) |> D.new() defp to_decimal(<<_::binary>>=b), do: D.new(b) defp to_decimal(_), do: D.new(0) defp prevent_org_txn_id_change(changeset, %{id: nil}), do: changeset defp prevent_org_txn_id_change(changeset, _struct) do if changed?(changeset, :org_txn_id) do add_error(changeset, :org_txn_id, "cannot be changed") else changeset end end end