defmodule DaProductApp.Settlements.AlipayPlus.CsvParser do @moduledoc """ Parser for AlipayPlus settlement CSV files. Handles parsing the two-section CSV format (summary and detail sections) and validates the data according to AlipayPlus specifications. """ require Logger @type summary_data :: %{ settle_date: Date.t(), value_date: Date.t(), fund_direction: String.t(), settlement_currency: String.t(), net_settlement_amount_value: Decimal.t(), transaction_currency: String.t() | nil, net_transaction_amount_value: Decimal.t() | nil, extend_info: String.t() | nil } @type detail_data :: %{ clearing_batch_id: String.t(), clearing_date: Date.t(), total_count: integer(), fund_direction: String.t(), settlement_currency: String.t(), net_settlement_amount_value: Decimal.t(), transaction_currency: String.t() | nil, net_transaction_amount_value: Decimal.t() | nil, extend_info: String.t() | nil } @type parsed_settlement :: %{ filename: String.t(), participant_id: String.t(), settlement_currency: String.t(), settlement_batch_id: String.t(), participant_agreement_id: String.t(), sequence: String.t(), summary: summary_data(), details: [detail_data()] } @doc """ Parses an AlipayPlus settlement CSV file. The file contains two sections: 1. Summary section - describes the overall settlement 2. Detail section - lists clearing cycles related to the settlement """ @spec parse_file(String.t(), binary()) :: {:ok, parsed_settlement()} | {:error, any()} def parse_file(filename, content) do with {:ok, file_metadata} <- parse_filename(filename), {:ok, lines} <- validate_and_split_content(content), {:ok, summary, details} <- parse_sections(lines) do parsed_data = %{ filename: filename, participant_id: file_metadata.participant_id, settlement_currency: file_metadata.settlement_currency, settlement_batch_id: file_metadata.settlement_batch_id, participant_agreement_id: file_metadata.participant_agreement_id, sequence: file_metadata.sequence, summary: summary, details: details } {:ok, parsed_data} else {:error, reason} -> {:error, reason} end end @doc """ Parses filename to extract metadata according to AlipayPlus convention. Format: settlement_____.csv """ @spec parse_filename(String.t()) :: {:ok, map()} | {:error, String.t()} def parse_filename(filename) do case Regex.run(~r/^settlement_(\w+)_([A-Z]{3})_(\d{18})_([A-Z0-9]+)_(\d{3})\.csv$/, filename) do [_, participant_id, currency, batch_id, agreement_id, seq] -> {:ok, %{ participant_id: participant_id, settlement_currency: currency, settlement_batch_id: batch_id, participant_agreement_id: agreement_id, sequence: seq }} nil -> {:error, "Invalid filename format: #{filename}"} end end @doc """ Converts amount from smallest currency unit to standard decimal value. Example: 1960 (smallest unit) -> 19.60 (AED) """ @spec convert_amount_from_smallest_unit(String.t(), String.t()) :: Decimal.t() def convert_amount_from_smallest_unit(amount_str, currency) do amount = String.to_integer(amount_str) divisor = get_currency_divisor(currency) amount |> Decimal.new() |> Decimal.div(Decimal.new(divisor)) end @doc """ Validates the structure and content of the CSV file. """ @spec validate_csv_structure([String.t()]) :: {:ok, [String.t()]} | {:error, String.t()} def validate_csv_structure(lines) do if length(lines) < 2 do {:error, "CSV file must contain at least summary and detail sections"} else summary_header = Enum.at(lines, 0) detail_header = find_detail_header(lines) cond do not valid_summary_header?(summary_header) -> {:error, "Invalid summary section header"} is_nil(detail_header) -> {:error, "Detail section not found"} not valid_detail_header?(detail_header) -> {:error, "Invalid detail section header"} true -> {:ok, lines} end end end # Private functions defp validate_and_split_content(content) do if String.valid?(content) do lines = content |> String.trim() |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) validate_csv_structure(lines) else {:error, "Invalid UTF-8 content"} end end defp parse_sections(lines) do with {:ok, summary_section, remaining_lines} <- extract_summary_section(lines), {:ok, detail_section} <- extract_detail_section(remaining_lines), {:ok, summary} <- parse_summary_data(summary_section), {:ok, details} <- parse_detail_data(detail_section) do {:ok, summary, details} else {:error, reason} -> {:error, reason} end end defp extract_summary_section([header | [data | rest]]) do if valid_summary_header?(header) do {:ok, [header, data], rest} else {:error, "Invalid summary section"} end end defp extract_summary_section(_), do: {:error, "Insufficient data for summary section"} defp extract_detail_section(lines) do case Enum.find_index(lines, &valid_detail_header?/1) do nil -> {:error, "Detail section not found"} index -> {:ok, Enum.drop(lines, index)} end end defp parse_summary_data([header, data]) do header_fields = String.split(header, ",") data_fields = String.split(data, ",") if length(header_fields) == length(data_fields) do summary_map = Enum.zip(header_fields, data_fields) |> Enum.into(%{}) try do summary = %{ settle_date: parse_date(summary_map["settleDate"]), value_date: parse_date(summary_map["valueDate"]), fund_direction: summary_map["fundDirection"], settlement_currency: summary_map["settlementCurrency"], net_settlement_amount_value: convert_amount_from_smallest_unit( summary_map["netSettlementAmountValue"], summary_map["settlementCurrency"] ), transaction_currency: parse_optional_field(summary_map["transactionCurrency"]), net_transaction_amount_value: parse_optional_amount( summary_map["netTransactionAmountValue"], summary_map["transactionCurrency"] ), extend_info: parse_optional_field(summary_map["extendInfo"]) } {:ok, summary} rescue error -> {:error, "Failed to parse summary data: #{inspect(error)}"} end else {:error, "Summary header and data field count mismatch"} end end defp parse_detail_data([header | data_rows]) do header_fields = String.split(header, ",") details = data_rows |> Enum.map(fn row -> data_fields = String.split(row, ",") if length(header_fields) == length(data_fields) do detail_map = Enum.zip(header_fields, data_fields) |> Enum.into(%{}) %{ clearing_batch_id: detail_map["clearingBatchId"], clearing_date: parse_date(detail_map["clearingDate"]), total_count: String.to_integer(detail_map["totalCount"]), fund_direction: detail_map["fundDirection"], settlement_currency: detail_map["settlementCurrency"], net_settlement_amount_value: convert_amount_from_smallest_unit( detail_map["netSettlementAmountValue"], detail_map["settlementCurrency"] ), transaction_currency: parse_optional_field(detail_map["transactionCurrency"]), net_transaction_amount_value: parse_optional_amount( detail_map["netTransactionAmountValue"], detail_map["transactionCurrency"] ), extend_info: parse_optional_field(detail_map["extendInfo"]) } else nil end end) |> Enum.reject(&is_nil/1) {:ok, details} rescue error -> {:error, "Failed to parse detail data: #{inspect(error)}"} end defp valid_summary_header?(header) do required_fields = [ "settleDate", # "valueDate", # Make valueDate optional for compatibility with current CSVs "fundDirection", "settlementCurrency", "netSettlementAmountValue" ] header_fields = String.split(header, ",") Enum.all?(required_fields, &(&1 in header_fields)) end defp valid_detail_header?(header) do required_fields = [ "clearingBatchId", # "clearingDate", # Make clearingDate optional for compatibility # "totalCount", # Make totalCount optional for compatibility "fundDirection", "settlementCurrency", "netSettlementAmountValue" ] header_fields = String.split(header, ",") Enum.all?(required_fields, &(&1 in header_fields)) end defp find_detail_header(lines) do Enum.find(lines, &valid_detail_header?/1) end defp parse_date(date_str) when is_binary(date_str) and byte_size(date_str) == 8 do year = String.slice(date_str, 0, 4) |> String.to_integer() month = String.slice(date_str, 4, 2) |> String.to_integer() day = String.slice(date_str, 6, 2) |> String.to_integer() Date.new!(year, month, day) end defp parse_optional_field(""), do: nil defp parse_optional_field(nil), do: nil defp parse_optional_field(value), do: value defp parse_optional_amount("", _currency), do: nil defp parse_optional_amount(nil, _currency), do: nil defp parse_optional_amount(amount_str, currency) do convert_amount_from_smallest_unit(amount_str, currency) end defp get_currency_divisor("AED"), do: 100 defp get_currency_divisor("USD"), do: 100 defp get_currency_divisor("EUR"), do: 100 # JPY doesn't use decimal places defp get_currency_divisor("JPY"), do: 1 # KRW doesn't use decimal places defp get_currency_divisor("KRW"), do: 1 # Default to 100 for most currencies defp get_currency_divisor(_), do: 100 end