Skip to main content

lib/phoenix_kit_referrals.ex

defmodule PhoenixKitReferrals do
  @moduledoc """
  Referral code system for PhoenixKit - complete management in a single module.

  This module provides both the Ecto schema definition and business logic for
  managing referral codes. It includes code creation, validation, usage tracking,
  and system configuration.

  ## Schema Fields

  - `code`: The referral code string (unique, required)
  - `description`: Human-readable description of the code
  - `status`: Boolean indicating if the code is active
  - `number_of_uses`: Current number of times the code has been used
  - `max_uses`: Maximum number of times the code can be used
  - `created_by`: User ID of the admin who created the code
  - `beneficiary`: User ID who benefits when this code is used (optional)
  - `date_created`: When the code was created
  - `expiration_date`: When the code expires

  ## Core Functions

  ### Code Management
  - `list_codes/0` - Get all referral codes
  - `get_code!/1` - Get a referral code by ID (raises if not found)
  - `get_code_by_string/1` - Get a referral code by its string value
  - `create_code/1` - Create a new referral code
  - `update_code/2` - Update an existing referral code
  - `delete_code/1` - Delete a referral code
  - `generate_random_code/0` - Generate a random code string

  ### Usage Tracking
  - `use_code/2` - Record usage of a referral code by a user
  - `get_usage_stats/1` - Get usage statistics for a code
  - `list_usage_for_code/1` - Get all usage records for a code
  - `user_used_code?/2` - Check if user has used a specific code

  ### System Settings
  - `enabled?/0` - Check if referral codes system is enabled
  - `required?/0` - Check if referral codes are required for registration
  - `enable_system/0` - Enable the referral codes system
  - `disable_system/0` - Disable the referral codes system
  - `set_required/1` - Set whether referral codes are required

  ## Usage Examples

      # Check if system is enabled
      if PhoenixKitReferrals.enabled?() do
        # System is active
      end

      # Create a new referral code
      {:ok, code} = PhoenixKitReferrals.create_code(%{
        code: "WELCOME2024",
        description: "Welcome promotion",
        max_uses: 100,
        created_by_uuid: admin_user.uuid,
        expiration_date: ~U[2024-12-31 23:59:59.000000Z]
      })

      # Use a referral code during registration
      case PhoenixKitReferrals.use_code("WELCOME2024", user_uuid) do
        {:ok, usage} -> # Code used successfully
        {:error, reason} -> # Handle error
      end
  """

  use Ecto.Schema
  use PhoenixKit.Module
  # Gettext macros bound to the module's own catalogs, for the permission
  # metadata label/description (resolved at call time = render time).
  use Gettext, backend: PhoenixKitReferrals.Gettext

  import Ecto.Changeset
  import Ecto.Query, warn: false

  alias PhoenixKit.Dashboard.Tab
  alias PhoenixKit.Settings
  alias PhoenixKit.Utils.Date, as: UtilsDate
  alias PhoenixKit.Utils.UUID, as: UUIDUtils
  alias PhoenixKitReferrals.ReferralCodeUsage
  @primary_key {:uuid, UUIDv7, autogenerate: true}

  schema "phoenix_kit_referral_codes" do
    field(:code, :string)
    field(:description, :string)
    field(:status, :boolean, default: true)
    field(:number_of_uses, :integer, default: 0)
    field(:max_uses, :integer)
    field(:created_by_uuid, UUIDv7)
    field(:beneficiary_uuid, UUIDv7)
    field(:date_created, :utc_datetime)
    field(:expiration_date, :utc_datetime)

    belongs_to(:creator, PhoenixKit.Users.Auth.User,
      foreign_key: :created_by_uuid,
      references: :uuid,
      define_field: false,
      type: UUIDv7
    )

    belongs_to(:beneficiary_user, PhoenixKit.Users.Auth.User,
      foreign_key: :beneficiary_uuid,
      references: :uuid,
      define_field: false,
      type: UUIDv7
    )

    has_many(:usage_records, ReferralCodeUsage, foreign_key: :code_uuid, references: :uuid)
  end

  ## --- Schema Functions ---

  @doc """
  Creates a changeset for referral code creation and updates.

  Validates that code is unique and all required fields are present.
  Automatically sets date_created on new records.
  """
  def changeset(referral_code, attrs) do
    referral_code
    |> cast(attrs, [
      :code,
      :description,
      :status,
      :number_of_uses,
      :max_uses,
      :created_by_uuid,
      :beneficiary_uuid,
      :date_created,
      :expiration_date
    ])
    |> validate_required([:code, :description, :max_uses])
    |> validate_length(:code, min: 3, max: 50)
    |> validate_length(:description, min: 1, max: 255)
    |> validate_number(:max_uses, greater_than: 0)
    |> validate_max_uses_limit()
    |> validate_number(:number_of_uses, greater_than_or_equal_to: 0)
    |> validate_code_uniqueness()
    |> unique_constraint(:code)
    |> validate_expiration_date()
    |> maybe_set_date_created()
    |> maybe_set_default_expiration()
  end

  @doc """
  Generates a random 5-character alphanumeric referral code.

  Returns a string with uppercase letters and numbers, excluding
  potentially confusing characters (0, O, I, 1).

  ## Examples

      iex> PhoenixKitReferrals.generate_random_code()
      "A7B2K"
  """
  def generate_random_code do
    # Exclude confusing characters: 0, O, I, 1
    chars = ~w(A B C D E F G H J K L M N P Q R S T U V W X Y Z 2 3 4 5 6 7 8 9)

    chars
    |> Enum.take_random(5)
    |> Enum.join()
  end

  @doc """
  Checks if a referral code is currently valid for use.

  A code is valid if:
  - It exists and is active (status: true)
  - It has not exceeded its maximum uses
  - It has not expired

  ## Examples

      iex> PhoenixKitReferrals.valid_for_use?(code)
      true
  """
  def valid_for_use?(%__MODULE__{} = code) do
    code.status &&
      code.number_of_uses < code.max_uses &&
      (is_nil(code.expiration_date) ||
         DateTime.compare(UtilsDate.utc_now(), code.expiration_date) == :lt)
  end

  @doc """
  Checks if a referral code has expired.

  ## Examples

      iex> PhoenixKitReferrals.expired?(code)
      false
  """
  def expired?(%__MODULE__{} = code) do
    !is_nil(code.expiration_date) &&
      DateTime.compare(UtilsDate.utc_now(), code.expiration_date) != :lt
  end

  @doc """
  Checks if a referral code has reached its usage limit.

  ## Examples

      iex> PhoenixKitReferrals.usage_limit_reached?(code)
      false
  """
  def usage_limit_reached?(%__MODULE__{} = code) do
    code.number_of_uses >= code.max_uses
  end

  ## --- Business Logic Functions ---

  @doc """
  Returns the list of referral codes ordered by creation date.

  ## Examples

      iex> PhoenixKitReferrals.list_codes()
      [%PhoenixKitReferrals{}, ...]
  """
  def list_codes do
    __MODULE__
    |> order_by([r], desc: r.date_created)
    |> preload([:creator, :beneficiary_user])
    |> repo().all()
  end

  @doc """
  Gets a single referral code by integer ID or UUID.

  Accepts:
  - Integer ID (e.g., `123`)
  - UUID string (e.g., `"550e8400-e29b-41d4-a716-446655440000"`)
  - Integer string (e.g., `"123"`)
  - Any other input returns `nil`

  Returns the referral code if found, `nil` otherwise.

  ## Examples

      iex> PhoenixKitReferrals.get_code(123)
      %PhoenixKitReferrals{}

      iex> PhoenixKitReferrals.get_code("550e8400-e29b-41d4-a716-446655440000")
      %PhoenixKitReferrals{}

      iex> PhoenixKitReferrals.get_code("123")
      %PhoenixKitReferrals{}

      iex> PhoenixKitReferrals.get_code(456)
      nil

      iex> PhoenixKitReferrals.get_code(:invalid)
      nil
  """
  def get_code(id) when is_binary(id) do
    if UUIDUtils.valid?(id) do
      repo().get_by(__MODULE__, uuid: id)
    else
      nil
    end
  end

  def get_code(_), do: nil

  @doc """
  Same as `get_code/1`, but raises `Ecto.NoResultsError` if the code does not exist.

  ## Examples

      iex> PhoenixKitReferrals.get_code!("550e8400-e29b-41d4-a716-446655440000")
      %PhoenixKitReferrals{}

      iex> PhoenixKitReferrals.get_code!("00000000-0000-0000-0000-000000000000")
      ** (Ecto.NoResultsError)
  """
  def get_code!(id) do
    case get_code(id) do
      nil -> raise Ecto.NoResultsError, queryable: __MODULE__
      code -> code
    end
  end

  @doc """
  Gets a single referral code by its string value.

  Returns the referral code if found, nil otherwise.

  ## Examples

      iex> PhoenixKitReferrals.get_code_by_string("WELCOME2024")
      %PhoenixKitReferrals{}

      iex> PhoenixKitReferrals.get_code_by_string("INVALID")
      nil
  """
  def get_code_by_string(code_string) when is_binary(code_string) do
    repo().get_by(__MODULE__, code: code_string)
  end

  @doc """
  Creates a referral code.

  ## Examples

      iex> PhoenixKitReferrals.create_code(%{code: "TEST123", max_uses: 10})
      {:ok, %PhoenixKitReferrals{}}

      iex> PhoenixKitReferrals.create_code(%{code: ""})
      {:error, %Ecto.Changeset{}}
  """
  def create_code(attrs \\ %{}) do
    %__MODULE__{}
    |> changeset(attrs)
    |> repo().insert()
  end

  @doc """
  Updates a referral code.

  ## Examples

      iex> PhoenixKitReferrals.update_code(code, %{description: "Updated"})
      {:ok, %PhoenixKitReferrals{}}

      iex> PhoenixKitReferrals.update_code(code, %{code: ""})
      {:error, %Ecto.Changeset{}}
  """
  def update_code(%__MODULE__{} = referral_code, attrs) do
    referral_code
    |> changeset(attrs)
    |> repo().update()
  end

  @doc """
  Deletes a referral code.

  ## Examples

      iex> PhoenixKitReferrals.delete_code(code)
      {:ok, %PhoenixKitReferrals{}}

      iex> PhoenixKitReferrals.delete_code(code)
      {:error, %Ecto.Changeset{}}
  """
  def delete_code(%__MODULE__{} = referral_code) do
    repo().delete(referral_code)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking referral code changes.

  ## Examples

      iex> PhoenixKitReferrals.change_code(code)
      %Ecto.Changeset{data: %PhoenixKitReferrals{}}
  """
  def change_code(%__MODULE__{} = referral_code, attrs \\ %{}) do
    changeset(referral_code, attrs)
  end

  @doc """
  Records usage of a referral code by a user.

  Validates that the code is valid for use before recording the usage.
  Updates the code's number_of_uses counter.

  ## Examples

      iex> PhoenixKitReferrals.use_code("WELCOME2024", user_uuid)
      {:ok, %PhoenixKitReferrals.ReferralCodeUsage{}}

      iex> PhoenixKitReferrals.use_code("EXPIRED", user_uuid)
      {:error, :code_not_found}
  """
  def use_code(code_string, user_uuid) when is_binary(code_string) and is_binary(user_uuid) do
    if UUIDUtils.valid?(user_uuid) do
      case get_code_by_string(code_string) do
        nil -> {:error, :code_not_found}
        code -> process_code_usage(code, user_uuid)
      end
    else
      {:error, :invalid_user_uuid}
    end
  end

  defp process_code_usage(code, user_uuid) do
    case valid_for_use?(code) do
      true -> record_code_usage(code, user_uuid)
      false -> get_code_error(code)
    end
  end

  defp record_code_usage(code, user_uuid) do
    repo().transaction(fn -> do_record_usage(code, user_uuid) end)
  end

  defp do_record_usage(code, user_uuid) do
    user_uuid = resolve_user_uuid(user_uuid)

    usage_result =
      %ReferralCodeUsage{}
      |> ReferralCodeUsage.changeset(%{
        code_uuid: code.uuid,
        used_by_uuid: user_uuid
      })
      |> repo().insert()

    case usage_result do
      {:ok, usage} ->
        {:ok, _updated_code} = update_code(code, %{number_of_uses: code.number_of_uses + 1})
        usage

      {:error, changeset} ->
        repo().rollback(changeset)
    end
  end

  defp get_code_error(code) do
    cond do
      expired?(code) -> {:error, :code_expired}
      usage_limit_reached?(code) -> {:error, :usage_limit_reached}
      !code.status -> {:error, :code_inactive}
      true -> {:error, :code_invalid}
    end
  end

  @doc """
  Gets usage statistics for a referral code.

  ## Examples

      iex> PhoenixKitReferrals.get_usage_stats(code_uuid)
      %{total_uses: 5, unique_users: 3, last_used: ~U[...], recent_users: [...]}
  """
  def get_usage_stats(code_uuid) when is_binary(code_uuid) do
    ReferralCodeUsage.get_usage_stats(code_uuid)
  end

  @doc """
  Lists all usage records for a referral code.

  ## Examples

      iex> PhoenixKitReferrals.list_usage_for_code(code_uuid)
      [%PhoenixKitReferrals.ReferralCodeUsage{}, ...]
  """
  def list_usage_for_code(code_uuid) when is_binary(code_uuid) do
    ReferralCodeUsage.for_code(code_uuid)
    |> repo().all()
  end

  @doc """
  Checks if a user has already used a specific referral code.

  ## Examples

      iex> PhoenixKitReferrals.user_used_code?(user_uuid, code_uuid)
      false
  """
  def user_used_code?(user_uuid, code_uuid) when is_binary(user_uuid) and is_binary(code_uuid) do
    ReferralCodeUsage.user_used_code?(user_uuid, code_uuid)
  end

  ## --- System Settings ---

  @impl PhoenixKit.Module
  @doc """
  Checks if the referral codes system is enabled.

  Returns true if the "referral_codes_enabled" setting is true. Any error
  (e.g. the database not being available yet at startup) is rescued and
  treated as disabled, so callers never need to special-case boot ordering.

  ## Examples

      iex> PhoenixKitReferrals.enabled?()
      false
  """
  def enabled? do
    Settings.get_boolean_setting("referral_codes_enabled", false)
  rescue
    _ -> false
  end

  @doc """
  Checks if referral codes are required for user registration.

  Returns true if the "referral_codes_required" setting is true.

  ## Examples

      iex> PhoenixKitReferrals.required?()
      false
  """
  def required? do
    Settings.get_boolean_setting("referral_codes_required", false)
  end

  @impl PhoenixKit.Module
  @doc """
  Enables the referral codes system.

  Sets the "referral_codes_enabled" setting to true.

  ## Examples

      iex> PhoenixKitReferrals.enable_system()
      {:ok, %Setting{}}
  """
  def enable_system do
    Settings.update_boolean_setting_with_module("referral_codes_enabled", true, "referral_codes")
  end

  @impl PhoenixKit.Module
  @doc """
  Disables the referral codes system.

  Sets the "referral_codes_enabled" setting to false.

  ## Examples

      iex> PhoenixKitReferrals.disable_system()
      {:ok, %Setting{}}
  """
  def disable_system do
    Settings.update_boolean_setting_with_module("referral_codes_enabled", false, "referral_codes")
  end

  @doc """
  Sets whether referral codes are required for registration.

  ## Examples

      iex> PhoenixKitReferrals.set_required(true)
      {:ok, %Setting{}}

      iex> PhoenixKitReferrals.set_required(false)
      {:ok, %Setting{}}
  """
  def set_required(required) when is_boolean(required) do
    Settings.update_boolean_setting_with_module(
      "referral_codes_required",
      required,
      "referral_codes"
    )
  end

  @doc """
  Gets the maximum number of uses allowed per referral code.

  Returns the system-wide limit for how many times a single referral code can be used.
  Defaults to 100 if not set.

  ## Examples

      iex> PhoenixKitReferrals.get_max_uses_per_code()
      100
  """
  def get_max_uses_per_code do
    Settings.get_integer_setting("max_number_of_uses_per_code", 100)
  end

  @doc """
  Gets the maximum number of referral codes a single user can create.

  Returns the system-wide limit for referral code creation per user.
  Defaults to 10 if not set.

  ## Examples

      iex> PhoenixKitReferrals.get_max_codes_per_user()
      10
  """
  def get_max_codes_per_user do
    Settings.get_integer_setting("max_number_of_codes_per_user", 10)
  end

  @doc """
  Sets the maximum number of uses allowed per referral code.

  Updates the system-wide limit for referral code usage.

  ## Examples

      iex> PhoenixKitReferrals.set_max_uses_per_code(50)
      {:ok, %Setting{}}
  """
  def set_max_uses_per_code(max_uses) when is_integer(max_uses) and max_uses > 0 do
    Settings.update_setting_with_module(
      "max_number_of_uses_per_code",
      to_string(max_uses),
      "referral_codes"
    )
  end

  @doc """
  Sets the maximum number of referral codes a single user can create.

  Updates the system-wide limit for referral code creation per user.

  ## Examples

      iex> PhoenixKitReferrals.set_max_codes_per_user(5)
      {:ok, %Setting{}}
  """
  def set_max_codes_per_user(max_codes) when is_integer(max_codes) and max_codes > 0 do
    Settings.update_setting_with_module(
      "max_number_of_codes_per_user",
      to_string(max_codes),
      "referral_codes"
    )
  end

  @impl PhoenixKit.Module
  @doc """
  Gets the current referral codes system configuration.

  Returns a map with the current settings.

  ## Examples

      iex> PhoenixKitReferrals.get_config()
      %{enabled: false, required: false}
  """
  def get_config do
    %{
      enabled: enabled?(),
      required: required?(),
      max_uses_per_code: get_max_uses_per_code(),
      max_codes_per_user: get_max_codes_per_user()
    }
  end

  # ============================================================================
  # Module Behaviour Callbacks
  # ============================================================================

  @impl PhoenixKit.Module
  def module_key, do: "referrals"

  @impl PhoenixKit.Module
  def module_name, do: "Referrals"

  @impl PhoenixKit.Module
  @doc "Module version, shown on the admin Modules page. Keep in sync with `mix.exs`."
  def version, do: "0.2.0"

  @impl PhoenixKit.Module
  def permission_metadata do
    %{
      key: "referrals",
      label: gettext("Referrals"),
      icon: "hero-gift",
      description: gettext("Referrals, tracking, and reward programs")
    }
  end

  @impl PhoenixKit.Module
  def admin_tabs do
    [
      Tab.new!(
        id: :admin_users_referral_codes,
        label: "Referrals",
        icon: "hero-ticket",
        path: "users/referral-codes",
        priority: 260,
        level: :admin,
        parent: :admin_users,
        permission: "referrals",
        gettext_backend: PhoenixKitReferrals.Gettext
      )
    ]
  end

  @impl PhoenixKit.Module
  def settings_tabs do
    [
      Tab.new!(
        id: :admin_settings_referrals,
        label: "Referrals",
        icon: "hero-gift",
        path: "referral-codes",
        priority: 920,
        level: :admin,
        parent: :admin_settings,
        permission: "referrals",
        gettext_backend: PhoenixKitReferrals.Gettext
      )
    ]
  end

  @impl PhoenixKit.Module
  def route_module, do: PhoenixKitReferrals.Routes

  @impl PhoenixKit.Module
  @doc "OTP apps whose templates Tailwind should scan for CSS classes."
  def css_sources, do: [:phoenix_kit_referrals]

  @doc """
  Gets codes that are currently valid for use.

  Returns codes that are active, not expired, and haven't reached usage limits.

  ## Examples

      iex> PhoenixKitReferrals.list_valid_codes()
      [%PhoenixKitReferrals{}, ...]
  """
  def list_valid_codes do
    now = UtilsDate.utc_now()

    from(r in __MODULE__,
      where: r.status == true,
      where: r.expiration_date > ^now,
      where: r.number_of_uses < r.max_uses,
      order_by: [desc: r.date_created]
    )
    |> repo().all()
  end

  @doc """
  Gets summary statistics for the referral codes system.

  Returns counts and metrics useful for admin dashboards.

  ## Examples

      iex> PhoenixKitReferrals.get_system_stats()
      %{total_codes: 10, active_codes: 8, total_usage: 150, codes_with_usage: 6}
  """
  def get_system_stats do
    codes_query = from(r in __MODULE__)
    usage_query = from(u in ReferralCodeUsage)

    total_codes = repo().aggregate(codes_query, :count)
    active_codes = repo().aggregate(from(r in codes_query, where: r.status == true), :count)
    total_usage = repo().aggregate(usage_query, :count)

    codes_with_usage =
      repo().aggregate(from(r in codes_query, where: r.number_of_uses > 0), :count)

    %{
      total_codes: total_codes,
      active_codes: active_codes,
      total_usage: total_usage,
      codes_with_usage: codes_with_usage
    }
  end

  ## --- Private Helpers ---

  defp validate_code_uniqueness(changeset) do
    case get_field(changeset, :code) do
      nil ->
        changeset

      # Let validate_required handle empty strings
      "" ->
        changeset

      code_string ->
        case get_code_by_string(code_string) do
          # No duplicate found, validation passes
          nil ->
            changeset

          existing_code ->
            # Check if this is the same record we're editing
            current_uuid = get_field(changeset, :uuid)

            if current_uuid && existing_code.uuid == current_uuid do
              # This is the same record, validation passes
              changeset
            else
              # Different record with same code, validation fails
              add_error(changeset, :code, "has already been taken")
            end
        end
    end
  end

  defp validate_expiration_date(changeset) do
    case get_field(changeset, :expiration_date) do
      nil ->
        changeset

      expiration_date ->
        if DateTime.compare(expiration_date, UtilsDate.utc_now()) == :gt do
          changeset
        else
          add_error(changeset, :expiration_date, "must be in the future")
        end
    end
  end

  defp maybe_set_date_created(changeset) do
    if changeset.data.__meta__.state == :built do
      put_change(changeset, :date_created, UtilsDate.utc_now())
    else
      changeset
    end
  end

  defp validate_max_uses_limit(changeset) do
    case get_field(changeset, :max_uses) do
      nil ->
        changeset

      max_uses ->
        system_limit = get_max_uses_per_code()

        if max_uses <= system_limit do
          changeset
        else
          add_error(changeset, :max_uses, "cannot exceed system limit of #{system_limit}")
        end
    end
  end

  @doc """
  Validates that a user hasn't exceeded their referral code creation limit.

  Checks the current number of codes created by the user against the system limit.
  Returns `{:ok, :valid}` if within limits, `{:error, reason}` if limit exceeded.

  ## Examples

      iex> PhoenixKitReferrals.validate_user_code_limit(1)
      {:ok, :valid}

      iex> PhoenixKitReferrals.validate_user_code_limit(1)
      {:error, "You have reached the maximum limit of 10 referral codes"}
  """
  def validate_user_code_limit(user_uuid) when is_binary(user_uuid) do
    max_codes = get_max_codes_per_user()
    current_count = count_user_codes(user_uuid)

    if current_count < max_codes do
      {:ok, :valid}
    else
      {:error, "You have reached the maximum limit of #{max_codes} referral codes"}
    end
  end

  @doc """
  Counts the total number of referral codes created by a user.

  ## Examples

      iex> PhoenixKitReferrals.count_user_codes(1)
      5
  """
  def count_user_codes(user_uuid) when is_binary(user_uuid) do
    if UUIDUtils.valid?(user_uuid) do
      from(r in __MODULE__, where: r.created_by_uuid == ^user_uuid, select: count(r.uuid))
      |> repo().one()
    else
      0
    end
  end

  defp maybe_set_default_expiration(changeset) do
    # Respect user's intent to leave expiration empty (nil = no expiration)
    # Only set default expiration for programmatic creation without explicit intent
    changeset
  end

  # Resolves user UUID from any user identifier
  defp resolve_user_uuid(user_uuid) when is_binary(user_uuid), do: user_uuid

  # Gets the configured repository for database operations
  defp repo do
    PhoenixKit.RepoHelper.repo()
  end
end