defmodule DaProductApp.Settlements.AlipayPlus.SftpClient do @moduledoc """ SFTP client for AlipayPlus settlement file retrieval. Handles connecting to AlipayPlus SFTP servers and downloading settlement files according to the naming convention and directory structure. """ require Logger @type connection_opts :: [ host: String.t(), port: integer(), username: String.t(), password: String.t(), connect_timeout: integer() ] @type file_info :: %{ filename: String.t(), path: String.t(), size: integer(), modified_time: DateTime.t() } @doc """ Establishes SFTP connection to AlipayPlus server. """ @spec connect(connection_opts()) :: {:ok, pid()} | {:error, any()} def connect(opts) do host = Keyword.fetch!(opts, :host) |> String.to_charlist() port = Keyword.get(opts, :port, 22) username = Keyword.fetch!(opts, :username) |> String.to_charlist() password = Keyword.fetch!(opts, :password) |> String.to_charlist() connect_timeout = Keyword.get(opts, :connect_timeout, 30_000) ssh_opts = [ {:user, username}, {:password, password}, {:connect_timeout, connect_timeout}, {:silently_accept_hosts, true} ] case :ssh.connect(host, port, ssh_opts) do {:ok, ssh_ref} -> case :ssh_sftp.start_channel(ssh_ref) do {:ok, sftp_ref} -> Logger.info("SFTP connection established to #{opts[:host]}") {:ok, %{ssh: ssh_ref, sftp: sftp_ref}} {:error, reason} -> :ssh.close(ssh_ref) Logger.error("Failed to start SFTP channel: #{inspect(reason)}") {:error, reason} end {:error, reason} -> Logger.error("Failed to connect to SFTP server #{opts[:host]}: #{inspect(reason)}") {:error, reason} end end @doc """ Closes the SFTP connection. """ @spec disconnect(%{ssh: pid(), sftp: pid()}) :: :ok def disconnect(%{ssh: ssh_ref, sftp: sftp_ref}) do :ssh_sftp.stop_channel(sftp_ref) :ssh.close(ssh_ref) Logger.info("SFTP connection closed") :ok end @doc """ Lists settlement files in the specified directory for a given participant and date. Directory structure: /v1/settlements/settlement// File pattern: settlement_____.csv """ @spec list_settlement_files(%{sftp: pid()}, String.t(), String.t(), String.t()) :: {:ok, [file_info()]} | {:error, any()} def list_settlement_files(connection, participant_id, date, environment \\ "v1") do directory = build_directory_path(environment, participant_id, date) case :ssh_sftp.list_dir(connection.sftp, String.to_charlist(directory)) do {:ok, files} -> settlement_files = files |> Enum.map(&List.to_string/1) |> Enum.filter(&is_settlement_file?(&1, participant_id)) |> Enum.map(&build_file_info(&1, directory, connection)) |> Enum.filter(fn {:ok, _} -> true _ -> false end) |> Enum.map(fn {:ok, file_info} -> file_info end) {:ok, settlement_files} {:error, reason} -> Logger.error("Failed to list directory #{directory}: #{inspect(reason)}") {:error, reason} end end @doc """ Downloads a specific settlement file from the SFTP server. """ @spec download_file(%{sftp: pid()}, String.t(), String.t()) :: {:ok, binary()} | {:error, any()} def download_file(connection, remote_path, local_path \\ nil) do remote_path_charlist = String.to_charlist(remote_path) if local_path do local_path_charlist = String.to_charlist(local_path) case :ssh_sftp.read_file(connection.sftp, remote_path_charlist) do {:ok, content} -> case File.write(local_path, content) do :ok -> Logger.info("Downloaded #{remote_path} to #{local_path}") {:ok, content} {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end else case :ssh_sftp.read_file(connection.sftp, remote_path_charlist) do {:ok, content} -> Logger.info("Downloaded #{remote_path} to memory") {:ok, content} {:error, reason} -> Logger.error("Failed to download #{remote_path}: #{inspect(reason)}") {:error, reason} end end end @doc """ Validates if a filename matches AlipayPlus settlement file pattern. """ @spec validate_filename(String.t(), String.t()) :: boolean() def validate_filename(filename, expected_participant_id) do regex = ~r/^settlement_#{expected_participant_id}_[A-Z]{3}_\d{18}_[A-Z0-9]+_\d{3}\.csv$/ String.match?(filename, regex) end # Private functions defp build_directory_path("sandbox", participant_id, date) do "/sandbox/settlements/settlement/#{participant_id}/#{date}" end defp build_directory_path("v1", participant_id, date) do "/v1/settlements/settlement/#{participant_id}/#{date}" end defp is_settlement_file?(filename, participant_id) do String.starts_with?(filename, "settlement_#{participant_id}_") and String.ends_with?(filename, ".csv") end defp build_file_info(filename, directory, connection) do full_path = "#{directory}/#{filename}" case :ssh_sftp.read_file_info(connection.sftp, String.to_charlist(full_path)) do {:ok, file_info} -> {:ok, %{ filename: filename, path: full_path, size: elem(file_info, 1), modified_time: parse_file_time(elem(file_info, 4)) }} {:error, reason} -> {:error, reason} end end defp parse_file_time({{year, month, day}, {hour, min, sec}}) do {:ok, datetime} = DateTime.new( Date.new!(year, month, day), Time.new!(hour, min, sec), "Etc/UTC" ) datetime end end