defmodule DaProductApp.Acquirer do @moduledoc """ Acquirer context module - Public API for all acquirer operations. This follows Phoenix context patterns and encapsulates all acquirer-related business logic for any acquirer network (YSP, VISA, MasterCard, etc.). Provides a clean, generic interface for: - Transaction processing (sales, reversals, etc.) - Terminal management - STAN generation - Batch management - Settlement operations """ import Ecto.Query, warn: false require Logger alias DaProductApp.Repo alias DaProductApp.Acquirer.Schemas.{ PosTransaction, PosTempTransaction, AcquirerTerminal, AcquirerTerminalStan, PosReversal, AcquirerBatch } alias DaProductApp.Acquirer.ReversalOrchestrator # ====================== # TRANSACTION OPERATIONS # ====================== @doc """ Creates a new temporary transaction for processing. """ def create_temp_transaction(attrs \\ %{}) do now = DateTime.utc_now() attrs = Map.merge(attrs, %{created_dateTime: now, updated_dateTime: now}) %PosTempTransaction{} |> PosTempTransaction.changeset(attrs) |> Repo.insert() end @doc """ Gets a temporary transaction by switch identifiers. """ def get_temp_transaction_by_switch_ids(s_tid, s_mid, s_tid_stan) do Repo.get_by(PosTempTransaction, s_tid: s_tid, s_mid: s_mid, s_tid_stan: s_tid_stan) end @doc """ Updates temporary transaction status. """ def update_temp_transaction_status(temp_transaction, status) do temp_transaction |> PosTempTransaction.status_changeset(status) |> Repo.update() end @doc """ Converts temporary transaction to permanent transaction. """ @doc """ Finalizes a temp transaction by creating permanent record and deleting temp record. If DB operation fails, executes reversal IMMEDIATELY (synchronous). If reversal also fails, marks temp transaction as REVERSAL_FAILED for cleanup worker retry. Returns: - {:ok, pos_transaction} - Success - {:error, {:finalize_failed, reason, reversal_created: id}} - DB failed, reversal succeeded - {:error, {:finalize_failed, reason, reversal_failed: error}} - DB failed, reversal failed """ def finalize_transaction(temp_transaction, additional_attrs \\ %{}) do result = Repo.transaction(fn -> # Create permanent transaction pos_transaction_attrs = temp_transaction |> Map.from_struct() |> Map.drop([:__meta__, :id, :status, :retry_count, :error_message, :timeout_at, :original_message, :reversal_retry_count, :last_reversal_attempt_at, :reversal_initiated_reason]) |> Map.merge(additional_attrs) case create_pos_transaction(pos_transaction_attrs) do {:ok, pos_transaction} -> # Remove temporary transaction case Repo.delete(temp_transaction) do {:ok, _} -> pos_transaction {:error, reason} -> Repo.rollback({:delete_failed, reason}) end {:error, reason} -> Repo.rollback({:create_failed, reason}) end end) # If transaction failed, execute reversal IMMEDIATELY (synchronous) case result do {:error, {:create_failed, reason}} -> Logger.error("DB create failed - executing IMMEDIATE reversal") execute_immediate_reversal(temp_transaction, "DB_CREATE_FAILED", reason) {:error, {:delete_failed, reason}} -> Logger.error("DB delete failed - executing IMMEDIATE reversal") execute_immediate_reversal(temp_transaction, "DB_DELETE_FAILED", reason) success -> success end end # Execute reversal immediately and synchronously (separate transaction) defp execute_immediate_reversal(temp_txn, reversal_reason, db_error) do case ReversalOrchestrator.create_automatic_reversal(temp_txn, reversal_reason) do {:ok, reversal} -> Logger.info("✅ Reversal #{reversal.id} created successfully for temp txn #{temp_txn.id}") {:error, {:finalize_failed, db_error, reversal_created: reversal.id}} {:error, reversal_error} -> Logger.error("🔴 Reversal creation FAILED for temp txn #{temp_txn.id}: #{inspect(reversal_error)}") # Mark temp txn for cleanup worker to retry mark_temp_txn_for_cleanup_retry(temp_txn, reversal_reason, reversal_error) {:error, {:finalize_failed, db_error, reversal_failed: reversal_error}} end end # Mark temp transaction for cleanup worker to retry reversal defp mark_temp_txn_for_cleanup_retry(temp_txn, reason, error) do from(t in PosTempTransaction, where: t.id == ^temp_txn.id) |> Repo.update_all( set: [ status: "REVERSAL_FAILED", reversal_initiated_reason: reason, error_message: "Reversal failed: #{inspect(error)}", last_reversal_attempt_at: DateTime.utc_now(), updated_at: DateTime.utc_now() ] ) Logger.warning("Marked temp txn #{temp_txn.id} as REVERSAL_FAILED - cleanup worker will retry") end @doc """ Creates a permanent POS transaction. """ def create_pos_transaction(attrs \\ %{}) do # Remove any datetime fields that don't exist in schema - Ecto timestamps() handles this attrs = Map.drop(attrs, [:created_dateTime, :updated_dateTime]) %PosTransaction{} |> PosTransaction.changeset(attrs) |> Repo.insert() end @doc """ Gets a POS transaction by switch identifiers. """ def get_pos_transaction_by_switch_ids(s_tid, s_mid, s_tid_stan) do Repo.get_by(PosTransaction, s_tid: s_tid, s_mid: s_mid, s_tid_stan: s_tid_stan) end @doc """ Finds original transaction for reversal. """ def find_original_transaction(s_tid, s_mid, original_stan, original_date) do from(t in PosTransaction, where: t.s_tid == ^s_tid and t.s_mid == ^s_mid and t.s_tid_stan == ^original_stan and t.b_tid_date == ^original_date, order_by: [desc: t.created_dateTime], limit: 1 ) |> Repo.one() end # =================== # REVERSAL OPERATIONS # =================== @doc """ Creates a reversal transaction. """ def create_reversal(attrs \\ %{}) do now = DateTime.utc_now() attrs = Map.merge(attrs, %{created_dateTime: now, updated_dateTime: now}) %PosReversal{} |> PosReversal.changeset(attrs) |> Repo.insert() end @doc """ Processes a reversal against an original transaction. """ def process_reversal(original_transaction, reversal_attrs) do Repo.transaction(fn -> # Create reversal record reversal_attrs = reversal_attrs |> Map.put(:original_transaction_id, original_transaction.id) |> Map.put(:original_s_tid, original_transaction.s_tid) |> Map.put(:original_s_mid, original_transaction.s_mid) |> Map.put(:original_s_stan, original_transaction.s_tid_stan) |> Map.put(:original_b_stan, original_transaction.b_tid_stan) |> Map.put(:original_reference_no, original_transaction.reference_no) |> Map.put(:original_approval_code, original_transaction.approval_code) |> Map.put(:original_amount, original_transaction.total_amount) |> Map.put(:original_date, original_transaction.b_tid_date) |> Map.put(:original_time, original_transaction.b_tid_time) case create_reversal(reversal_attrs) do {:ok, reversal} -> reversal {:error, reason} -> Repo.rollback(reason) end end) end # ==================== # TERMINAL OPERATIONS # ==================== @doc """ Gets terminal configuration by switch identifiers. """ def get_terminal_by_switch_ids(switch_tid, switch_mid) do AcquirerTerminal.find_by_switch_ids(switch_tid, switch_mid) end @doc """ Creates or updates terminal configuration. """ def upsert_terminal(attrs) do now = DateTime.utc_now() attrs = attrs |> Map.put_new(:created_dateTime, now) |> Map.put(:updated_dateTime, now) %AcquirerTerminal{} |> AcquirerTerminal.changeset(attrs) |> Repo.insert() end # =============== # STAN OPERATIONS # =============== @doc """ Gets the next STAN for an acquirer/terminal combination. """ def next_stan(acquirer_id, terminal_id) do AcquirerTerminalStan.next_stan(acquirer_id, terminal_id) end @doc """ Formats STAN as 6-digit string. """ def format_stan(stan_value), do: AcquirerTerminalStan.format_stan(stan_value) # ================ # BATCH OPERATIONS # ================ @doc """ Gets current open batch for a terminal. """ def get_current_batch(acquirer_id, terminal_id) do from(b in AcquirerBatch, where: b.acquirer_id == ^acquirer_id and b.tid == ^terminal_id and b.status == "OPEN", order_by: [desc: b.created_dateTime], limit: 1 ) |> Repo.one() end @doc """ Creates a new batch for a terminal. """ def create_batch(acquirer_id, terminal_id, batch_attrs \\ %{}) do today = Date.utc_today() |> Date.to_string() |> String.replace("-", "") time_now = Time.utc_now() |> Time.to_string() |> String.slice(0..5) |> String.replace(":", "") now = DateTime.utc_now() # Generate next batch number (simple increment, could be more sophisticated) next_batch_no = generate_next_batch_number(acquirer_id, terminal_id) attrs = batch_attrs |> Map.merge(%{ batch_no: next_batch_no, acquirer_id: acquirer_id, tid: terminal_id, batch_date: today, batch_time: time_now, open_date: today, open_time: time_now, status: "OPEN", created_dateTime: now, updated_dateTime: now }) %AcquirerBatch{} |> AcquirerBatch.changeset(attrs) |> Repo.insert() end @doc """ Adds a transaction to a batch and updates totals. """ def add_transaction_to_batch(batch, transaction_type, amount, tip_amount \\ 0) do batch |> AcquirerBatch.update_totals_changeset(transaction_type, amount, tip_amount) |> Repo.update() end @doc """ Closes a batch for settlement. """ def close_batch(batch) do today = Date.utc_today() |> Date.to_string() |> String.replace("-", "") time_now = Time.utc_now() |> Time.to_string() |> String.slice(0..5) |> String.replace(":", "") batch |> AcquirerBatch.changeset(%{ status: "CLOSED", close_date: today, close_time: time_now, updated_dateTime: DateTime.utc_now() }) |> Repo.update() end # ================== # UTILITY FUNCTIONS # ================== @doc """ Lists timed out temporary transactions for cleanup. """ def list_timed_out_temp_transactions(timeout_minutes \\ 15) do timeout_threshold = DateTime.add(DateTime.utc_now(), -timeout_minutes * 60, :second) from(t in PosTempTransaction, where: t.created_dateTime < ^timeout_threshold and t.status in ["CREATED", "VALIDATED", "SENT_TO_UPSTREAM", "WAITING_RESPONSE"], order_by: [asc: t.created_dateTime] ) |> Repo.all() end @doc """ Lists temp transactions with REVERSAL_FAILED status for retry by cleanup worker. These are transactions where immediate reversal (from DB or TCP failure) failed and need to be retried by the cleanup worker. Returns transactions ordered by last attempt time (oldest first). """ def list_failed_reversal_temp_transactions do from(t in PosTempTransaction, where: t.status == "REVERSAL_FAILED", order_by: [asc: t.last_reversal_attempt_at] ) |> Repo.all() end @doc """ Cleans up old temporary transactions by first attempting reversal, then deleting. CRITICAL: Never deletes without reversal processing (Core Principle #1). ## Options - `:force_delete` - If true, deletes without reversal (ops emergency only, logs warning). Default: false ## Returns - `{:ok, %{deleted: count, reversals_created: count, reversal_failed: count}}` ## Examples # Normal cleanup (reversal-first) Acquirer.cleanup_old_temp_transactions(24) # Emergency force delete (use with extreme caution) Acquirer.cleanup_old_temp_transactions(24, force_delete: true) """ def cleanup_old_temp_transactions(hours_old \\ 24, opts \\ []) do force_delete = Keyword.get(opts, :force_delete, false) cutoff_time = DateTime.add(DateTime.utc_now(), -hours_old * 3600, :second) candidates = from(t in PosTempTransaction, where: t.inserted_at < ^cutoff_time ) |> Repo.all() Logger.info("Found #{length(candidates)} old temp transactions (older than #{hours_old}h)") if force_delete do Logger.warning("⚠️ FORCE DELETE ENABLED - Deleting without reversals (emergency ops mode)") {count, _} = Repo.delete_all(from(t in PosTempTransaction, where: t.id in ^Enum.map(candidates, & &1.id))) {:ok, %{deleted: count, reversals_created: 0, reversal_failed: 0, force_delete: true}} else # Process reversals first using orchestrator results = Enum.map(candidates, fn temp_txn -> case ReversalOrchestrator.attempt_auto_reversal(temp_txn, "STARTUP_CLEANUP") do {:ok, reversal} -> # Reversal created successfully {:reversal_created, reversal.id} {:error, {:already_reversed, _}} -> # Already reversed, safe to delete {:safe_to_delete, temp_txn.id} {:error, {:max_retries_exceeded, _}} -> # Max retries exceeded, status already updated to PENDING_MANUAL_REVIEW # Don't delete - needs manual intervention {:needs_manual_review, temp_txn.id} {:error, reason} -> # Reversal failed for other reasons Logger.warning("Failed to create reversal for temp_txn #{temp_txn.id}: #{inspect(reason)}") {:reversal_failed, {temp_txn.id, reason}} end end) # Only delete transactions that are safe to delete (already reversed) safe_to_delete_ids = results |> Enum.filter(&match?({:safe_to_delete, _}, &1)) |> Enum.map(fn {:safe_to_delete, id} -> id end) {deleted_count, _} = if Enum.empty?(safe_to_delete_ids) do {0, nil} else Repo.delete_all( from(t in PosTempTransaction, where: t.id in ^safe_to_delete_ids) ) end reversals_created = Enum.count(results, &match?({:reversal_created, _}, &1)) failed_count = Enum.count(results, &match?({:reversal_failed, _}, &1)) manual_review_count = Enum.count(results, &match?({:needs_manual_review, _}, &1)) Logger.info("Cleanup complete: #{reversals_created} reversals created, #{deleted_count} deleted, #{failed_count} failed, #{manual_review_count} need manual review") {:ok, %{ deleted: deleted_count, reversals_created: reversals_created, reversal_failed: failed_count, needs_manual_review: manual_review_count }} end end # Private helper functions defp generate_next_batch_number(acquirer_id, terminal_id) do today = Date.utc_today() |> Date.to_string() |> String.replace("-", "") last_batch = from(b in AcquirerBatch, where: b.acquirer_id == ^acquirer_id and b.tid == ^terminal_id and b.batch_date == ^today, order_by: [desc: b.batch_no], limit: 1, select: b.batch_no ) |> Repo.one() case last_batch do nil -> "000001" batch_no -> next_num = String.to_integer(batch_no) + 1 String.pad_leading(Integer.to_string(next_num), 6, "0") end end # ============================== # ADDITIONAL FUNCTIONS FOR PHASE 3 # ============================== @doc """ Creates a final transaction from attributes (used by TransactionProcessor). """ def create_transaction(attrs \\ %{}) do create_pos_transaction(attrs) end @doc """ Gets a transaction by switch IDs (used by TransactionProcessor). """ def get_transaction_by_switch_ids(s_tid, s_mid, s_tid_stan) do get_pos_transaction_by_switch_ids(s_tid, s_mid, s_tid_stan) end @doc """ Finds terminal by switch IDs (used by TransactionProcessor). """ def find_terminal_by_switch_ids(switch_tid, switch_mid) do from(t in AcquirerTerminal, where: t.tid == ^switch_tid and t.mid == ^switch_mid and t.status == "ACTIVE" ) |> Repo.one() end @doc """ Updates reversal status. """ def update_reversal_status(%PosReversal{} = reversal, new_status) do reversal |> PosReversal.changeset(%{ status: new_status, updated_dateTime: DateTime.utc_now(), updated_by: "SYSTEM" }) |> Repo.update() end @doc """ Updates transaction response code (final transactions don't have a separate status field). """ def update_transaction_status(%PosTransaction{} = transaction, new_response_code) do transaction |> PosTransaction.changeset(%{ response_code: new_response_code, updated_dateTime: DateTime.utc_now(), updated_by: "SYSTEM" }) |> Repo.update() end @doc """ Gets terminal configuration by terminal ID and acquirer ID. Used by event listeners to get terminal configuration. """ def get_acquirer_terminal(terminal_id, acquirer_id) do from(t in AcquirerTerminal, where: t.tid == ^terminal_id and t.acquirer_id == ^acquirer_id and t.status == "ACTIVE" ) |> Repo.one() end end