defmodule DaProductApp.Merchants do @moduledoc """ Context for managing merchants enrolled by partners. Provides business logic for merchant registration, validation, and transaction processing in the UPI International PSP system. """ import Ecto.Query, warn: false alias DaProductApp.Repo alias DaProductApp.Merchants.Merchant alias DaProductApp.Partners.Partner # ================================ # MERCHANT CRUD OPERATIONS # ================================ @doc """ Create a new merchant under a partner. """ def create_merchant(partner_id, attrs) do with %Partner{} = partner <- get_partner!(partner_id), attrs <- Map.put(attrs, "partner_id", partner.id) do %Merchant{} |> Merchant.changeset(attrs) |> put_defaults_for_corridor(attrs["corridor"]) |> Repo.insert() else nil -> {:error, "Partner not found"} error -> error end end @doc """ Get merchant by ID with partner information. """ def get_merchant(id) do Merchant |> preload(:partner) |> Repo.get(id) end @doc """ Get merchant by ID, raising if not found. """ def get_merchant!(id) do Merchant |> preload(:partner) |> Repo.get!(id) end @doc """ Get merchant by merchant code. """ def get_merchant_by_code(merchant_code) do Merchant |> where([m], m.merchant_code == ^merchant_code) |> preload(:partner) |> Repo.one() end @doc """ Get merchant by VPA. """ def get_merchant_by_vpa(vpa) do Merchant |> where([m], m.merchant_vpa == ^vpa) |> preload(:partner) |> Repo.one() end @doc """ Get merchant by MID (NPCI Merchant ID). """ def get_merchant_by_mid(mid) do Merchant |> where([m], m.mid == ^mid) |> preload(:partner) |> Repo.one() end @doc """ Get merchant by MSID (Merchant Store ID / Store ID). Used for dynamic QR validation when NPCI sends msid parameter. If multiple merchants have the same SID, returns the most recently created one. """ def get_merchant_by_msid(msid) do Merchant |> where([m], m.sid == ^msid) |> order_by([m], desc: m.inserted_at) |> limit(1) |> preload(:partner) |> Repo.one() end @doc """ Get merchant by TID (Terminal ID) and VPA (merchant_vpa). Used for dynamic QR validation when NPCI sends both mtid and pa parameters. This ensures we match the correct merchant when multiple merchants share the same TID. """ def get_merchant_by_tid_and_vpa(tid, vpa) when is_binary(tid) and is_binary(vpa) do Merchant |> where([m], m.tid == ^tid and m.merchant_vpa == ^vpa) |> order_by([m], desc: m.inserted_at) |> limit(1) |> preload(:partner) |> Repo.one() end def get_merchant_by_tid_and_vpa(_, _), do: nil @doc """ Get merchant by TID (Terminal ID). Some QRs include `mtid`/`tid` and we allow direct lookup by terminal id. """ def get_merchant_by_tid(tid) do # Some databases may contain duplicate/legacy rows for the same TID. # To avoid raising Ecto.MultipleResultsError we explicitly limit the # query to one row and pick the most recently inserted merchant. Merchant |> where([m], m.tid == ^tid) |> order_by([m], desc: m.inserted_at) |> limit(1) |> preload(:partner) |> Repo.one() end @doc """ Get merchant by settlement IFSC code. This is used when ReqPay contains an IFSC value and we need to find the merchant that owns that settlement account. """ def get_merchant_by_settlement_ifsc(ifsc) when is_binary(ifsc) and ifsc != "" do Merchant |> where([m], m.settlement_account_ifsc == ^ifsc) |> order_by([m], desc: m.inserted_at) |> limit(1) |> preload(:partner) |> Repo.one() end @doc """ Update merchant information. """ def update_merchant(%Merchant{} = merchant, attrs) do merchant |> Merchant.changeset(attrs) |> Repo.update() end @doc """ List all merchants for a partner. """ def list_partner_merchants(partner_id, opts \\ []) do query = from m in Merchant, where: m.partner_id == ^partner_id, preload: [:partner] query |> maybe_filter_by_status(opts[:status]) |> maybe_filter_by_corridor(opts[:corridor]) |> maybe_paginate(opts[:page], opts[:per_page]) |> Repo.all() end @doc """ Get merchant count for a partner. """ def count_partner_merchants(partner_id) do from(m in Merchant, where: m.partner_id == ^partner_id, select: count(m.id)) |> Repo.one() end @doc """ Delete a merchant (soft delete by marking inactive). """ def delete_merchant(%Merchant{} = merchant) do update_merchant(merchant, %{status: "INACTIVE"}) end # ================================ # BUSINESS OPERATIONS # ================================ @doc """ Validate merchant for transaction processing. """ def validate_merchant_for_transaction(merchant_id) do case get_merchant(merchant_id) do %Merchant{} = merchant -> cond do merchant.status != "ACTIVE" -> {:error, "Merchant is not active"} merchant.compliance_status != "VERIFIED" -> {:error, "Merchant compliance not verified"} not merchant.qr_enabled -> {:error, "QR payments disabled for merchant"} true -> {:ok, merchant} end nil -> {:error, "Merchant not found"} end end @doc """ Check if merchant can process the given transaction amount. """ def check_transaction_limits(%Merchant{} = merchant, amount) do decimal_amount = if is_binary(amount), do: Decimal.new(amount), else: amount cond do merchant.max_transaction_limit && Decimal.compare(decimal_amount, merchant.max_transaction_limit) == :gt -> {:error, "Amount exceeds merchant transaction limit"} not check_daily_limit(merchant, decimal_amount) -> {:error, "Amount exceeds daily transaction limit"} true -> :ok end end @doc """ Update merchant's last transaction timestamp. """ def update_last_transaction(merchant_id) do from(m in Merchant, where: m.id == ^merchant_id) |> Repo.update_all(set: [last_transaction_at: DateTime.utc_now()]) end @doc """ Update merchant status (ACTIVE, SUSPENDED, INACTIVE). """ def update_merchant_status(merchant_id, status) when status in ["ACTIVE", "SUSPENDED", "INACTIVE"] do case get_merchant(merchant_id) do %Merchant{} = merchant -> update_merchant(merchant, %{status: status}) nil -> {:error, "Merchant not found"} end end # ================================ # MERCHANT SEARCH & FILTERING # ================================ @doc """ Search merchants by various criteria. """ def search_merchants(params) do Merchant |> preload(:partner) |> maybe_filter_by_partner(params[:partner_id]) |> maybe_filter_by_status(params[:status]) |> maybe_filter_by_corridor(params[:corridor]) |> maybe_filter_by_business_type(params[:business_type]) |> maybe_search_by_name(params[:search]) |> maybe_order_by(params[:sort], params[:order]) |> maybe_paginate(params[:page], params[:per_page]) |> Repo.all() end # ================================ # INTERNATIONAL MERCHANT OPERATIONS # ================================ @doc """ Get merchants for a specific corridor. """ def list_corridor_merchants(corridor) do from(m in Merchant, where: m.corridor == ^corridor and m.status == "ACTIVE", preload: [:partner] ) |> Repo.all() end @doc """ Get merchant with FX configuration. """ def get_merchant_with_fx_config(merchant_id) do case get_merchant(merchant_id) do %Merchant{corridor: corridor, local_currency: currency} = merchant when corridor != "DOMESTIC" -> fx_config = %{ corridor: corridor, local_currency: currency, fx_markup_rate: merchant.fx_markup_rate || Decimal.new("0"), partner_code: merchant.partner.partner_code } {:ok, merchant, fx_config} %Merchant{} = merchant -> {:ok, merchant, nil} nil -> {:error, "Merchant not found"} end end # ================================ # PRIVATE HELPER FUNCTIONS # ================================ defp get_partner!(partner_id) do Repo.get(Partner, partner_id) end defp put_defaults_for_corridor(changeset, corridor) do case corridor do "SINGAPORE" -> changeset |> Ecto.Changeset.put_change(:country_code, "SG") |> Ecto.Changeset.put_change(:local_currency, "SGD") "UAE" -> changeset |> Ecto.Changeset.put_change(:country_code, "AE") |> Ecto.Changeset.put_change(:local_currency, "AED") "USA" -> changeset |> Ecto.Changeset.put_change(:country_code, "US") |> Ecto.Changeset.put_change(:local_currency, "USD") _ -> changeset |> Ecto.Changeset.put_change(:country_code, "IN") |> Ecto.Changeset.put_change(:local_currency, "INR") |> Ecto.Changeset.put_change(:corridor, "DOMESTIC") end end defp check_daily_limit(merchant, amount) do if merchant.daily_transaction_limit do today_start = DateTime.utc_now() |> DateTime.beginning_of_day() daily_total = from(t in "transactions", where: t.merchant_id == ^merchant.id and t.inserted_at >= ^today_start, select: sum(t.inr_amount) ) |> Repo.one() |> case do nil -> Decimal.new("0") total -> total end new_total = Decimal.add(daily_total, amount) Decimal.compare(new_total, merchant.daily_transaction_limit) != :gt else true end end # Query filter helpers defp maybe_filter_by_partner(query, nil), do: query defp maybe_filter_by_partner(query, partner_id) do where(query, [m], m.partner_id == ^partner_id) end defp maybe_filter_by_status(query, nil), do: query defp maybe_filter_by_status(query, status) do where(query, [m], m.status == ^status) end defp maybe_filter_by_corridor(query, nil), do: query defp maybe_filter_by_corridor(query, corridor) do where(query, [m], m.corridor == ^corridor) end defp maybe_filter_by_business_type(query, nil), do: query defp maybe_filter_by_business_type(query, business_type) do where(query, [m], m.business_type == ^business_type) end defp maybe_search_by_name(query, nil), do: query defp maybe_search_by_name(query, search_term) do search_pattern = "%#{search_term}%" where(query, [m], ilike(m.brand_name, ^search_pattern) or ilike(m.legal_name, ^search_pattern) or ilike(m.merchant_code, ^search_pattern) ) end defp maybe_order_by(query, nil, _), do: order_by(query, [m], desc: m.inserted_at) defp maybe_order_by(query, sort_field, order) when sort_field in ["name", "code", "created"] do field = case sort_field do "name" -> :brand_name "code" -> :merchant_code "created" -> :inserted_at end direction = if order == "desc", do: :desc, else: :asc order_by(query, [m], [{^direction, field(m, ^field)}]) end defp maybe_order_by(query, _, _), do: order_by(query, [m], desc: m.inserted_at) defp maybe_paginate(query, nil, _), do: query defp maybe_paginate(query, page, per_page) when is_integer(page) and is_integer(per_page) do offset = (page - 1) * per_page query |> limit(^per_page) |> offset(^offset) end defp maybe_paginate(query, _, _), do: query end