defmodule DaProductAppWeb.MerchantApiController do use DaProductAppWeb, :controller require Logger alias DaProductApp.Groups.Group alias DaProductApp.Brands.Brand alias DaProductApp.Stores.Store alias DaProductApp.Repo alias DaProductApp.PosTerminals.PosTerminal alias DaProductApp.Transactions.Transaction import Ecto.Query def get_store_detail(conn, %{"merchantRefId" => code} = _params) do Logger.info("Fetching store details for merchantRefId: #{code}") query = from g in Group, where: g.code == ^code, join: b in Brand, on: b.group_id == g.id, join: s in Store, on: s.brand_id == b.id, select: %{ store_id: s.id, store_name: s.name, store_code: s.code, brand_id: b.id, brand_name: b.name, neo_merchant_id: s.neo_merchant_id, address_id: s.address_id, created_at: s.inserted_at, updated_at: s.updated_at } case Repo.all(query) do [] -> conn |> put_status(:not_found) |> json(%{ status: "error", count: 0, message: "No stores found for the provided merchantRefId", data: %{ stores: [] } }) stores -> conn |> put_status(:ok) |> json(%{ status: "success", count: length(stores), message: "#{length(stores)} store(s) retrieved successfully", data: %{ stores: stores } }) end end def get_store_detail(conn, _params) do conn |> put_status(:bad_request) |> json(%{ status: "error", count: 0, message: "merchantRefId is required", data: %{ stores: [] } }) end def get_device_detail(conn, %{"merchantRefId" => code} = _params) do Logger.info("Fetching device details for merchantRefId: #{code}") query = from g in Group, where: g.code == ^code, join: b in Brand, on: b.group_id == g.id, join: s in Store, on: s.brand_id == b.id, join: pt in PosTerminal, on: pt.store_id == s.id, select: %{ device_id: pt.id, name: pt.name, serial_number: pt.serial_number, device_type: pt.device_type, neo_terminal_id: pt.neo_terminal_id, provider_id: pt.provider_id, store: %{ store_id: s.id, store_name: s.name, store_code: s.code, brand_id: b.id, brand_name: b.name, neo_merchant_id: s.neo_merchant_id, address_id: s.address_id }, brand: %{ id: b.id, name: b.name, code: b.code }, created_at: pt.inserted_at, updated_at: pt.updated_at } case Repo.all(query) do [] -> conn |> put_status(:not_found) |> json(%{ status: "error", deviceCount: 0, message: "No devices found for the provided merchantRefId", data: %{ devices: [] } }) devices -> conn |> put_status(:ok) |> json(%{ status: "success", deviceCount: length(devices), message: "#{length(devices)} device(s) retrieved successfully", data: %{ devices: devices } }) end end def get_device_detail(conn, _params) do conn |> put_status(:bad_request) |> json(%{ status: "error", deviceCount: 0, message: "merchantRefId is required", data: %{ devices: [] } }) end def get_today_transactions(conn, %{"merchantRefId" => code} = _params) do Logger.info("Fetching today's transactions for merchantRefId: #{code}") today = Date.utc_today() today_start = DateTime.new!(today, ~T[00:00:00.000], "Etc/UTC") today_end = DateTime.new!(today, ~T[23:59:59.999], "Etc/UTC") base_query = from g in Group, where: g.code == ^code, join: b in Brand, on: b.group_id == g.id, join: s in Store, on: s.brand_id == b.id, join: pt in PosTerminal, on: pt.store_id == s.id, left_join: t in Transaction, on: fragment("CAST(? AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci = CAST(? AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci", t.device_id, pt.serial_number) and t.inserted_at >= ^today_start and t.inserted_at <= ^today_end # Get settlement amount settlement_query = from [g, b, s, pt, t] in base_query, where: not is_nil(t.id) and not is_nil(t.settlement_date_time), select: %{ total_settlement_amount: coalesce(sum(t.transaction_amount), 0) } # Get transactions with details transactions_query = from [g, b, s, pt, t] in base_query, where: not is_nil(t.id), select: %{ transaction_id: t.id, transaction_amount: t.transaction_amount, device_id: t.device_id, processing_id: t.processing_id, status: t.status, email: t.email, user_id: t.user_id, transaction_ref_number: t.transaction_ref_number, m_ref_num: t.m_ref_num, name: t.name, merchant_id: t.merchant_id, additional_data: t.additional_data, payment_reference_id: t.payment_reference_id, payload: t.payload, settlement_date_time: t.settlement_date_time, is_settled: not is_nil(t.settlement_date_time), device_details: %{ device_id: pt.id, name: pt.name, serial_number: pt.serial_number, device_type: pt.device_type, neo_terminal_id: pt.neo_terminal_id }, store_details: %{ store_id: s.id, store_name: s.name, store_code: s.code, neo_merchant_id: s.neo_merchant_id }, brand_details: %{ brand_id: b.id, brand_name: b.name, brand_code: b.code }, created_at: t.inserted_at } with settlement = %{total_settlement_amount: settlement_amount} <- Repo.one(settlement_query), transactions when is_list(transactions) <- Repo.all(transactions_query) do total_amount = Enum.reduce(transactions, Decimal.new("0"), fn tx, acc -> Decimal.add(acc, tx.transaction_amount || Decimal.new("0")) end) conn |> put_status(:ok) |> json(%{ status: "success", count: length(transactions), total_amount: total_amount, total_settlement_amount: settlement_amount, message: "#{length(transactions)} transaction(s) retrieved successfully", data: %{ transactions: transactions } }) else [] -> conn |> put_status(:not_found) |> json(%{ status: "error", count: 0, total_amount: "0.00", total_settlement_amount: "0.00", message: "No transactions found for today", data: %{ transactions: [] } }) end end def get_today_transactions(conn, _params) do conn |> put_status(:bad_request) |> json(%{ status: "error", count: 0, total_amount: "0.00", total_settlement_amount: "0.00", message: "merchantRefId is required", data: %{ transactions: [] } }) end def get_total_transactions(conn,params) do merchantRefId = params["merchantRefId"] if is_nil(merchantRefId) do conn |> put_status(:bad_request) |> json(%{ status: "error", message: "merchantRefId is required", data: %{ transactions: [] } }) else Logger.info("Fetching transactions for merchantRefId: #{merchantRefId} with params: #{inspect(params)}") {start_date, end_date} = get_date_range(params) base_query = from g in Group, where: g.code == ^merchantRefId, join: b in Brand, on: b.group_id == g.id, join: s in Store, on: s.brand_id == b.id, join: pt in PosTerminal, on: pt.store_id == s.id, left_join: t in Transaction, on: fragment("CAST(? AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci = CAST(? AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci", t.device_id, pt.serial_number) and t.inserted_at >= ^start_date and t.inserted_at <= ^end_date # Get settlement amount settlement_query = from [g, b, s, pt, t] in base_query, where: not is_nil(t.id) and not is_nil(t.settlement_date_time), select: %{ total_settlement_amount: coalesce(sum(t.transaction_amount), 0) } # Get transactions with details transactions_query = from [g, b, s, pt, t] in base_query, where: not is_nil(t.id), order_by: [desc: t.inserted_at], select: %{ transaction_id: t.id, transaction_amount: t.transaction_amount, device_id: t.device_id, processing_id: t.processing_id, status: t.status, email: t.email, transaction_ref_number: t.transaction_ref_number, m_ref_num: t.m_ref_num, merchant_id: t.merchant_id, payment_reference_id: t.payment_reference_id, settlement_date_time: t.settlement_date_time, is_settled: not is_nil(t.settlement_date_time), provider_name: t.provider_name, device_details: %{ device_id: pt.id, name: pt.name, serial_number: pt.serial_number, device_type: pt.device_type, neo_terminal_id: pt.neo_terminal_id }, store_details: %{ store_id: s.id, store_name: s.name, store_code: s.code, neo_merchant_id: s.neo_merchant_id }, brand_details: %{ brand_id: b.id, brand_name: b.name, brand_code: b.code }, created_at: t.inserted_at } with settlement = %{total_settlement_amount: settlement_amount} <- Repo.one(settlement_query), transactions when is_list(transactions) <- Repo.all(transactions_query) do total_amount = Enum.reduce(transactions, Decimal.new("0"), fn tx, acc -> Decimal.add(acc, tx.transaction_amount || Decimal.new("0")) end) conn |> put_status(:ok) |> json(%{ status: "success", count: length(transactions), total_amount: total_amount, total_settlement_amount: settlement_amount, date_range: %{ start_date: DateTime.to_date(start_date), end_date: DateTime.to_date(end_date) }, message: "#{length(transactions)} transaction(s) retrieved successfully", data: %{ transactions: transactions } }) else [] -> conn |> put_status(:not_found) |> json(%{ status: "error", count: 0, total_amount: "0.00", total_settlement_amount: "0.00", date_range: %{ start_date: DateTime.to_date(start_date), end_date: DateTime.to_date(end_date) }, message: "No transactions found for the specified period", data: %{ transactions: [] } }) end end end def get_merchant_hierarchy(conn, %{"merchantRefId" => code} = _params) do Logger.info("Fetching merchant hierarchy for merchantRefId: #{code}") query = from g in Group, where: g.code == ^code, join: b in Brand, on: b.group_id == g.id, join: s in Store, on: s.brand_id == b.id, left_join: pt in PosTerminal, on: pt.store_id == s.id, select: %{ group_id: g.id, group_name: g.name, group_code: g.code, brand_id: b.id, brand_name: b.name, brand_code: b.code, store_id: s.id, store_name: s.name, store_code: s.code, neo_merchant_id: s.neo_merchant_id, terminal_id: pt.id, terminal_name: pt.name, serial_number: pt.serial_number, device_type: pt.device_type, neo_terminal_id: pt.neo_terminal_id } case Repo.all(query) do [] -> conn |> put_status(:not_found) |> json(%{ status: "error", message: "No merchant chains found for the provided merchantRefId", data: %{ merchantRefId: code, chains: [] } }) results -> hierarchy = results |> Enum.reduce(%{}, fn record, acc -> brand_key = record.brand_id store_key = record.store_id terminal = if record.terminal_id do %{ id: record.terminal_id, name: record.terminal_name, serial_number: record.serial_number, device_type: record.device_type, neo_terminal_id: record.neo_terminal_id } end acc |> Map.update(brand_key, %{ brand_id: record.brand_id, brand_name: record.brand_name, brand_code: record.brand_code, stores: %{ store_key => %{ store_id: record.store_id, store_name: record.store_name, store_code: record.store_code, neo_merchant_id: record.neo_merchant_id, terminals: [terminal] |> Enum.reject(&is_nil/1) } } }, fn brand -> brand |> Map.update!(:stores, fn stores -> stores |> Map.update(store_key, %{ store_id: record.store_id, store_name: record.store_name, store_code: record.store_code, neo_merchant_id: record.neo_merchant_id, terminals: [terminal] |> Enum.reject(&is_nil/1) }, fn store -> if terminal do Map.update!(store, :terminals, &([terminal | &1])) else store end end) end) end) end) formatted_hierarchy = %{ chain_id: hd(results).group_id, chain_name: hd(results).group_name, chain_code: hd(results).group_code, brands: hierarchy |> Map.values() |> Enum.map(fn brand -> %{brand | stores: brand.stores |> Map.values() |> Enum.map(fn store -> %{store | terminals: Enum.uniq_by(store.terminals, & &1.id) } end) } end) } conn |> put_status(:ok) |> json(%{ status: "success", message: "Merchant chains retrieved successfully", data: %{ merchantRefId: code, chains: formatted_hierarchy } }) end end def get_merchant_hierarchy(conn, _params) do conn |> put_status(:bad_request) |> json(%{ status: "error", message: "merchantRefId is required", data: %{ merchantRefId: nil, chains: [] } }) end def create_group(conn, params) do Logger.info("Creating new group with params: #{inspect(params)}") case validate_required_params(params) do :ok -> changeset = Group.changeset(%Group{}, %{ name: params["name"], code: params["code"], description: params["description"], status: params["status"] || "active", phone_number: params["phone_number"], transaction_currency: "AED" }) case Repo.insert(changeset) do {:ok, group} -> conn |> put_status(:created) |> json(%{ status: "success", success: true, message: "Chain successfully created", chain_id: group.id }) {:error, changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{ status: "error", success: false, message: "Failed to create chain", errors: Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) }) end {:error, missing_fields} -> conn |> put_status(:bad_request) |> json(%{ status: "error", success: false, message: "Missing required fields", errors: missing_fields }) end end def update_group(conn, params) do Logger.info("Updating group with params: #{inspect(params)}") with {:ok, id} <- validate_id(params["id"]), {:ok, update_params} <- validate_update_params(params), {:ok, group} <- get_group(id) do changeset = Group.changeset(group, %{ name: update_params["chain_name"] || group.name, code: update_params["chain_code"] || group.code, description: update_params["description"] || group.description, phone_number: update_params["phone_number"] || group.phone_number }) case Repo.update(changeset) do {:ok, _updated_group} -> conn |> put_status(:ok) |> json(%{ success: true, message: "Chain updated successfully" }) {:error, changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{ success: false, message: "Failed to update chain", errors: Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) }) end else {:error, :missing_id} -> conn |> put_status(:bad_request) |> json(%{ success: false, message: "Chain ID is required" }) {:error, :no_params} -> conn |> put_status(:bad_request) |> json(%{ success: false, message: "At least one parameter is required for update" }) {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{ success: false, message: "Chain not found" }) end end def create_store(conn, params) do Logger.info("Creating new store with params: #{inspect(params)}") with {:ok, chain_id} <- validate_chain_id(params["chain_id"]), {:ok, brand_id} <- validate_brand_id(params["brand_id"]), :ok <- validate_store_params(params), :ok <- validate_unique_store_code(params["store_code"]) do changeset = Store.changeset(%Store{}, %{ brand_id: brand_id, name: params["store_name"], code: params["store_code"], address_id: params["address_id"], neo_merchant_id: params["neo_merchant_id"], status: "active" }) case Repo.insert(changeset) do {:ok, store} -> conn |> put_status(:created) |> json(%{ status: "success", success: true, message: "Store added successfully", store_id: store.id, errors: nil }) {:error, changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{ status: "error", success: false, message: "Failed to create store", store_id: nil, errors: Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) }) end else {:error, :chain_not_found} -> conn |> put_status(:not_found) |> json(%{ status: "error", success: false, message: "Chain not found", store_id: nil, errors: "Chain not found" }) {:error, :brand_not_found} -> conn |> put_status(:not_found) |> json(%{ status: "error", success: false, message: "Brand not found", store_id: nil, errors: "Brand not found" }) {:error, :store_code_exists} -> conn |> put_status(:conflict) |> json(%{ status: "error", success: false, message: "Store code already exists", store_id: nil, errors: "Store code already exists" }) {:error, missing_fields} -> conn |> put_status(:bad_request) |> json(%{ status: "error", success: false, message: "Missing required fields", store_id: nil, errors: "Either store_code, store_name, address_id, neo_merchant_id, chain_id or brand_id is missed", }) end end def update_store(conn, params) do Logger.info("Updating store with params: #{inspect(params)}") with {:ok, id} <- validate_id(params["id"]), {:ok, store} <- get_store(id), :ok <- validate_unique_store_code_for_update(params["store_code"], id) do changeset = Store.changeset(store, %{ name: params["store_name"] || store.name, code: params["store_code"] || store.code, neo_merchant_id: params["neo_merchant_id"] || store.neo_merchant_id }) case Repo.update(changeset) do {:ok, updated_store} -> conn |> put_status(:ok) |> json(%{ status: "success", success: true, message: "Store information updated successfully", store_id: updated_store.id, errors: nil }) {:error, changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{ status: "error", success: false, message: "Failed to update store", errors: Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end), store_id: params["id"], }) end else {:error, :missing_id} -> conn |> put_status(:bad_request) |> json(%{ status: "error", success: false, message: "Store ID is required", errors: "Store ID is required", store_id: nil }) {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{ status: "error", success: false, message: "Store not found", errors: "Store not found", store_id: params["id"] }) {:error, :store_code_exists} -> conn |> put_status(:conflict) |> json(%{ status: "error", success: false, message: "Store code already exists", errors: "Store code already exists", store_id: params["id"] }) end end # Private functions to handle validation defp validate_id(id) when is_nil(id), do: {:error, :missing_id} defp validate_id(id) do case Integer.parse(to_string(id)) do {id, ""} -> {:ok, id} _ -> {:error, :missing_id} end end defp validate_update_params(params) do update_fields = ["chain_name", "chain_code", "description", "phone_number"] has_updates = Enum.any?(update_fields, &Map.has_key?(params, &1)) if has_updates do {:ok, params} else {:error, :no_params} end end defp get_group(id) do case Repo.get(Group, id) do nil -> {:error, :not_found} group -> {:ok, group} end end defp get_store(id) do case Repo.get(Store, id) do nil -> {:error, :not_found} store -> {:ok, store} end end defp validate_required_params(params) do required_fields = ["name", "code"] missing_fields = Enum.filter(required_fields, fn field -> is_nil(params[field]) || String.trim(params[field] || "") == "" end) case missing_fields do [] -> :ok fields -> errors = Enum.into(fields, %{}, fn field -> {field, "#{field} is required and cannot be empty"} end) {:error, errors} end end defp validate_chain_id(chain_id) when is_nil(chain_id), do: {:error, %{chain_id: "Chain ID is required"}} defp validate_chain_id(chain_id) do case Repo.get(Group, chain_id) do nil -> {:error, :chain_not_found} _group -> {:ok, chain_id} end end defp validate_brand_id(brand_id) when is_nil(brand_id), do: {:error, %{brand_id: "Brand ID is required"}} defp validate_brand_id(brand_id) do case Repo.get(Brand, brand_id) do nil -> {:error, :brand_not_found} _brand -> {:ok, brand_id} end end defp validate_store_params(params) do required_fields = ["store_code", "store_name", "address_id", "neo_merchant_id"] missing_fields = Enum.filter(required_fields, fn field -> is_nil(params[field]) || String.trim(to_string(params[field]) || "") == "" end) case missing_fields do [] -> :ok fields -> errors = Enum.into(fields, %{}, fn field -> {field, "#{field} is required and cannot be empty"} end) {:error, errors} end end defp validate_unique_store_code(store_code) do case Repo.get_by(Store, code: store_code) do nil -> :ok _store -> {:error, :store_code_exists} end end defp validate_unique_store_code_for_update(nil, _current_store_id), do: :ok defp validate_unique_store_code_for_update(store_code, current_store_id) do case Repo.get_by(Store, code: store_code) do nil -> :ok store -> if store.id == current_store_id do :ok else {:error, :store_code_exists} end end end defp get_date_range(%{"start_date" => start_date, "end_date" => end_date}) when not is_nil(start_date) and not is_nil(end_date) and start_date != "" and end_date != "" do { parse_datetime(start_date, :start), parse_datetime(end_date, :end) } end defp get_date_range(_params) do today = Date.utc_today() start_date = Date.beginning_of_month(today) end_date = Date.end_of_month(today) { DateTime.new!(start_date, ~T[00:00:00.000], "Etc/UTC"), DateTime.new!(end_date, ~T[23:59:59.999], "Etc/UTC") } end defp parse_datetime(date_string, bound) do date = Date.from_iso8601!(date_string) time = case bound do :start -> ~T[00:00:00.000] :end -> ~T[23:59:59.999] end DateTime.new!(date, time, "Etc/UTC") end end