defmodule DaProductApp.Transactions.Stan do @moduledoc """ STAN (System Trace Audit Number) management schema. Ported from Java entity: org.jpos.tcpay.db.entity.Stan Manages unique sequential numbering for transactions per terminal/acquirer. Elixir best practices applied: - Uses database-level constraints for uniqueness - Implements atomic increment operations - Provides rollover at 999999 (6-digit STAN) """ use Ecto.Schema import Ecto.Changeset import Ecto.Query, warn: false alias DaProductApp.Repo alias DaProductApp.YSP.AcquirerTerminal # Configure auto-increment primary key for MySQL @primary_key {:id, :id, autogenerate: true} schema "stan" do # acquirer_id is defined by belongs_to association below field :terminal_id, :string # Terminal ID field :current_stan, :integer # Current STAN value (1-999999) field :last_used_date, :date # Last date STAN was used field :daily_reset, :boolean, default: true # Whether to reset daily # Associations belongs_to :acquirer_terminal, AcquirerTerminal, foreign_key: :acquirer_id, references: :id timestamps() end @doc false def changeset(stan, attrs) do stan |> cast(attrs, [:terminal_id, :current_stan, :last_used_date, :daily_reset]) |> validate_required([:terminal_id, :current_stan]) |> validate_number(:current_stan, greater_than: 0, less_than: 1_000_000) |> validate_length(:terminal_id, max: 8) |> unique_constraint([:acquirer_id, :terminal_id]) |> foreign_key_constraint(:acquirer_id) end @doc """ Gets the next STAN for a given acquirer and terminal. Atomically increments and handles rollover/daily reset. """ def next_stan(acquirer_id, terminal_id) when is_integer(acquirer_id) and is_binary(terminal_id) do today = Date.utc_today() # Use database transaction for atomicity Repo.transaction(fn -> case get_or_create_stan_record(acquirer_id, terminal_id) do {:ok, stan_record} -> next_value = calculate_next_stan(stan_record, today) case update_stan_record(stan_record, next_value, today) do {:ok, _updated_record} -> {:ok, next_value} {:error, reason} -> Repo.rollback(reason) end {:error, reason} -> Repo.rollback(reason) end end) |> case do {:ok, {:ok, stan_value}} -> {:ok, stan_value} {:error, reason} -> {:error, reason} end end @doc """ Formats STAN as 6-digit string with leading zeros. """ def format_stan(stan_value) when is_integer(stan_value) do String.pad_leading(Integer.to_string(stan_value), 6, "0") end @doc """ Resets STAN to 1 for a given acquirer/terminal (for maintenance). """ def reset_stan(acquirer_id, terminal_id) when is_integer(acquirer_id) and is_binary(terminal_id) do from(s in __MODULE__, where: s.acquirer_id == ^acquirer_id and s.terminal_id == ^terminal_id ) |> Repo.update_all(set: [current_stan: 1, last_used_date: Date.utc_today()]) end # Private helper functions defp get_or_create_stan_record(acquirer_id, terminal_id) do case Repo.get_by(__MODULE__, acquirer_id: acquirer_id, terminal_id: terminal_id) do nil -> # Create new STAN record starting at 1 %__MODULE__{} |> changeset(%{ acquirer_id: acquirer_id, terminal_id: terminal_id, current_stan: 1, last_used_date: Date.utc_today(), daily_reset: true }) |> Repo.insert() stan_record -> {:ok, stan_record} end end defp calculate_next_stan(stan_record, today) do cond do # Daily reset enabled and it's a new day stan_record.daily_reset && stan_record.last_used_date != today -> 1 # Normal increment with rollover stan_record.current_stan >= 999999 -> 1 # Normal increment true -> stan_record.current_stan + 1 end end defp update_stan_record(stan_record, next_value, today) do stan_record |> changeset(%{current_stan: next_value, last_used_date: today}) |> Repo.update() end end