defmodule DaProductApp.Settlements.TransactionEodGenerator do @moduledoc """ Generates End-of-Day (EOD) settlement files with transaction-level data in CSV and JSON formats. This module implements the Settlement File Format Specification for CSV & JSON as defined in the YSP Settlement EOD API requirements. It generates files with: - Transaction-level data instead of merchant-level summaries - Proper SHA256 checksum validation - Both CSV and JSON output formats """ alias DaProductApp.Settlements.Settlement alias DaProductApp.Transactions.Transaction alias DaProductApp.Repo import Ecto.Query require Logger @file_type "QR_Payment_Settlement" @version "1.1" @generated_by "AaniQR System" @base_path "priv/static/settlements" @doc """ Generates settlement files (CSV and JSON) for a specific merchant and date. ## Options - `:merchant_id` - The merchant ID to generate files for - `:date` - The settlement date - `:format` - The output format (:csv, :json, or :both) - `:sequence` - The file sequence number (defaults to 1) ## Returns - `{:ok, %{csv: path, json: path}}` - If both formats generated - `{:ok, %{csv: path}}` - If only CSV generated - `{:ok, %{json: path}}` - If only JSON generated - `{:error, reason}` - If generation fails """ def generate_settlement_files(opts) do bank_user_id = Keyword.fetch!(opts, :bank_user_id) date = Keyword.fetch!(opts, :date) format = opts[:format] || :csv sequence = opts[:sequence] || 1 Logger.info( "Generating EOD files for bank_user_id: #{bank_user_id}, date: #{Date.to_iso8601(date)}" ) with {:ok, settlement_data} <- get_settlement_by_bank_user_id_and_date(bank_user_id, date), _ <- Logger.info( "[DEBUG-v3-FIXED] Settlement found: id=#{settlement_data.id}, settlement_id=#{settlement_data.settlement_id}" ), {:ok, transactions} <- get_transactions_by_settlement(settlement_data.settlement_id, bank_user_id), _ <- Logger.info("[DEBUG] Found #{length(transactions)} transactions"), true <- amounts_match?(transactions, settlement_data.gross_settlement_amount) do results = %{} results = if format in [:csv, :both] do case generate_csv_file(settlement_data, transactions, date, sequence) do {:ok, csv_path} -> Map.put(results, :csv, csv_path) {:error, reason} -> Logger.error("Failed to generate CSV: #{reason}") results end else results end results = if format in [:json, :both] do case generate_json_file(settlement_data, transactions, date, sequence) do {:ok, json_path} -> Map.put(results, :json, json_path) {:error, reason} -> Logger.error("Failed to generate JSON: #{reason}") results end else results end if map_size(results) > 0 do {:ok, results} else {:error, "Failed to generate any files"} end else false -> {:error, "Sum of transaction_amount does not match gross_settlement_amount"} {:error, reason} -> {:error, reason} end end @doc """ Generates CSV content according to the specification. """ def generate_csv_content(settlement_data, transactions, date) do header_lines = build_csv_header_lines(settlement_data, date) transaction_lines = build_csv_transaction_lines(transactions) footer_lines = build_csv_footer_lines(transactions) all_lines = header_lines ++ transaction_lines ++ footer_lines content_for_checksum = build_content_for_checksum(all_lines) checksum = calculate_checksum(content_for_checksum) final_content = all_lines |> insert_checksum(checksum) |> Enum.join("\n") final_content end @doc """ Generates JSON content according to the specification. """ def generate_json_content(settlement_data, transactions, date) do json_data = %{ fileType: @file_type, version: @version, settlementDate: Date.to_iso8601(date), merchantTag: settlement_data.merchant_tag, bankUserId: settlement_data.bank_user_id, merchantId: settlement_data.merchant_id, batchNumber: settlement_data.batch_number, totalTransactionCount: length(transactions), grossSettlementAmount: %{ value: Decimal.to_string(settlement_data.gross_settlement_amount || Decimal.new(0)), currency: settlement_data.gross_settlement_currency || "AED" }, mdrCharges: %{ value: Decimal.to_string(settlement_data.mdr_charges || Decimal.new(0)), currency: settlement_data.mdr_charges_currency || "AED" }, taxOnMdr: %{ value: Decimal.to_string(settlement_data.tax_on_mdr || Decimal.new(0)), currency: settlement_data.tax_on_mdr_currency || "AED" }, netSettlementAmount: %{ value: Decimal.to_string(settlement_data.net_settlement_amount || Decimal.new(0)), currency: settlement_data.net_settlement_currency || "AED" }, mismatchDetected: settlement_data.mismatch_count > 0, generatedTimestamp: format_timestamp(DateTime.utc_now()), transactions: Enum.map(transactions, &format_transaction_for_json/1), footer: %{ totalRowCount: length(transactions), checksum: calculate_checksum_for_json(transactions), fileGeneratedBy: @generated_by, endOfFile: true } } case Jason.encode(json_data, pretty: true) do {:ok, json_string} -> {:ok, json_string} {:error, reason} -> {:error, "JSON encoding failed: #{inspect(reason)}"} end end @doc """ Calculates SHA256 checksum for content. """ def calculate_checksum(content) do :crypto.hash(:sha256, content) |> Base.encode16() |> String.upcase() end # Private functions defp get_settlement_data(merchant_id, date) do settlement = Repo.one( from s in Settlement, where: s.merchant_id == ^merchant_id and s.date == ^date, limit: 1 ) case settlement do nil -> {:error, "No settlement found for merchant #{merchant_id} on #{Date.to_iso8601(date)}"} settlement -> {:ok, settlement} end end defp get_settlement_by_bank_user_id_and_date(bank_user_id, date) do settlement = Repo.one( from s in Settlement, where: s.bank_user_id == ^bank_user_id and s.date == ^date, limit: 1 ) case settlement do nil -> {:error, "No settlement found for bank_user_id #{bank_user_id} on #{Date.to_iso8601(date)}"} settlement -> {:ok, settlement} end end defp get_settlement_transactions(settlement) do transactions = Repo.all( from st in SettlementTransaction, where: st.settlement_id == ^settlement.id, order_by: [asc: st.transaction_time] ) {:ok, transactions} end defp get_transactions_by_settlement(settlement_id_string, bank_user_id) do # FORCE RECOMPILE - settlement_id should be business ID not UUID Logger.info( "[DEBUG-v3-FIXED] Querying transactions with settlement_id: #{settlement_id_string}, bank_user_id: #{bank_user_id}" ) transactions = Repo.all( from t in Transaction, where: t.settlement_id == ^settlement_id_string and t.bank_user_id == ^bank_user_id, order_by: [asc: t.settlement_date_time] ) Logger.info("[DEBUG-v3-FIXED] Query returned #{length(transactions)} transactions") {:ok, transactions} end defp amounts_match?(transactions, gross_settlement_amount) do sum = transactions |> Enum.map(fn transaction -> amount = case transaction.transaction_amount do nil -> Decimal.new(0) %Decimal{} = decimal -> decimal number when is_number(number) -> Decimal.new(number) string when is_binary(string) -> Decimal.new(string) _ -> Decimal.new(0) end Logger.debug( "[DEBUG] Transaction #{transaction.transaction_id}: amount = #{Decimal.to_string(amount)}" ) amount end) |> Enum.reduce(Decimal.new(0), fn amt, acc -> Decimal.add(acc, amt) end) # Accept string/float/Decimal for gross_settlement_amount gross = case gross_settlement_amount do nil -> Decimal.new(0) %Decimal{} = decimal -> decimal number when is_number(number) -> Decimal.new(number) string when is_binary(string) -> Decimal.new(string) _ -> Decimal.new(0) end Logger.info( "[EOD] Sum of transaction_amount: #{Decimal.to_string(sum)}, gross_settlement_amount: #{Decimal.to_string(gross)}" ) Decimal.compare(sum, gross) == :eq end defp generate_csv_file(settlement_data, transactions, date, sequence) do csv_content = generate_csv_content(settlement_data, transactions, date) merchant_id = settlement_data.merchant_id || "unknown_merchant" filename = generate_filename(merchant_id, date, sequence, "csv") file_path = Path.join([@base_path, merchant_id, Date.to_iso8601(date), filename]) # Ensure directory exists File.mkdir_p!(Path.dirname(file_path)) case File.write(file_path, csv_content) do :ok -> {:ok, file_path} {:error, reason} -> {:error, "Failed to write CSV file: #{reason}"} end end defp generate_json_file(settlement_data, transactions, date, sequence) do case generate_json_content(settlement_data, transactions, date) do {:ok, json_content} -> merchant_id = settlement_data.merchant_id || "unknown_merchant" filename = generate_filename(merchant_id, date, sequence, "json") file_path = Path.join([@base_path, merchant_id, Date.to_iso8601(date), filename]) # Ensure directory exists File.mkdir_p!(Path.dirname(file_path)) case File.write(file_path, json_content) do :ok -> {:ok, file_path} {:error, reason} -> {:error, "Failed to write JSON file: #{reason}"} end {:error, reason} -> {:error, reason} end end defp build_csv_header_lines(settlement_data, date) do settlement_date = Date.to_iso8601(date) generated_timestamp = format_timestamp(DateTime.utc_now()) [ "#FileType,#{@file_type}", "#Version,#{@version}", "#SettlementDate,#{settlement_date}", "#MerchantTag,#{settlement_data.merchant_tag || ""}", "#BankUserID,#{settlement_data.bank_user_id || ""}", "#MID,#{settlement_data.merchant_id}", "#TotalTransactionCount,#{settlement_data.total_transaction_count || 0}", "#GrossSettlementAmount,#{format_decimal(settlement_data.gross_settlement_amount)},#{settlement_data.gross_settlement_currency || "AED"}", "#MDRCharges,#{format_decimal(settlement_data.mdr_charges)},#{settlement_data.mdr_charges_currency || "AED"}", "#TaxOnMDR,#{format_decimal(settlement_data.tax_on_mdr)},#{settlement_data.tax_on_mdr_currency || "AED"}", "#NetSettlementAmount,#{format_decimal(settlement_data.net_settlement_amount)},#{settlement_data.net_settlement_currency || "AED"}", "#MismatchDetected,#{if settlement_data.mismatch_count > 0, do: "YES", else: "NO"}", "#GeneratedTimestamp,#{generated_timestamp}", "QRTransactionID,QRID,TID,TransactionAmount,Currency,TransactionStatus,TransactionTime,MDRCharge,TaxOnMDR,NetReceived,BatchNumber" ] end defp build_csv_transaction_lines(transactions) do Enum.map(transactions, fn transaction -> [ transaction.transaction_id || "", # QRID transaction.uhid || "", # TID transaction.device_id || "", format_decimal(transaction.transaction_amount), # Always AED for currency "AED", transaction.status || "", format_timestamp(transaction.settlement_date_time), # mdr_charge default 0 "0", # tax_on_mdr default 0 "0", # net_received_amount default 0 "0", # Default batch number, could be made configurable "000001" ] |> Enum.join(",") end) end defp build_csv_footer_lines(transactions) do total_rows = length(transactions) [ "#TotalRowCount,#{total_rows}", "#Checksum,PLACEHOLDER_CHECKSUM", "#FileGeneratedBy,#{@generated_by}", "#EndOfFile" ] end defp build_content_for_checksum(lines) do lines |> Enum.reject(fn line -> String.starts_with?(line, "#Checksum") or String.starts_with?(line, "#EndOfFile") end) |> Enum.join("\n") end defp insert_checksum(lines, checksum) do Enum.map(lines, fn line -> if String.starts_with?(line, "#Checksum,PLACEHOLDER_CHECKSUM") do "#Checksum,#{checksum}" else line end end) end defp calculate_checksum_for_json(transactions) do # For JSON, we calculate checksum based on transaction data transaction_content = Enum.map(transactions, fn t -> "#{t.transaction_id},#{t.uhid},#{t.device_id},#{format_decimal(t.transaction_amount)},#{t.transaction_currency},#{t.transaction_status}" end) |> Enum.join("\n") calculate_checksum(transaction_content) end defp format_transaction_for_json(transaction) do %{ transactionId: transaction.transaction_id, # QRID qrId: transaction.uhid, # TID terminalId: transaction.device_id, transactionAmount: %{ value: format_decimal(transaction.transaction_amount), currency: transaction.transaction_currency || "AED" }, transactionStatus: transaction.transaction_status || transaction.status, transactionTime: format_timestamp(transaction.settlement_date_time), mdrCharge: %{ value: format_decimal(transaction.mdr_charge), currency: transaction.mdr_charge_currency || "AED" }, taxOnMdr: %{ value: format_decimal(transaction.tax_on_mdr), currency: transaction.tax_on_mdr_currency || "AED" }, netReceivedAmount: %{ value: format_decimal(transaction.net_received_amount), currency: transaction.net_received_currency || "AED" } } end defp generate_filename(merchant_id, date, sequence, extension) do mmdd = date |> Date.to_string() |> String.slice(5, 5) |> String.replace("-", "") hhmmss = DateTime.utc_now() |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8) |> String.replace(":", "") seq_str = sequence |> Integer.to_string() |> String.pad_leading(2, "0") "mercury_pay_#{mmdd}_#{hhmmss}_#{seq_str}.#{extension}" end defp format_decimal(nil), do: "0" defp format_decimal(decimal) when is_struct(decimal, Decimal) do Decimal.to_string(decimal) end defp format_decimal(value), do: to_string(value) defp format_timestamp(nil), do: "" defp format_timestamp(datetime) do datetime |> DateTime.to_iso8601() # Convert to IST as per specification |> String.replace("Z", "+05:30") end def generate_csv(settlement, transactions) do ([ "#FileType,QR_Payment_Settlement", "#Version,1.1", "#SettlementDate,#{settlement.settlement_date}", "#MerchantTag,#{settlement.merchant_tag}", "#BankUserID,#{settlement.bank_user_id}", "#MID,#{settlement.mid}", "#TotalTransactionCount,#{settlement.total_transaction_count}", "#GrossSettlementAmount,#{settlement.gross_amount},AED", "#MDRCharges,#{settlement.mdr_charges},AED", "#TaxOnMDR,#{settlement.tax_on_mdr},AED", "#NetSettlementAmount,#{settlement.net_amount},AED", "#MismatchDetected,#{settlement.mismatch_detected}", "#GeneratedTimestamp,#{settlement.generated_at}", "#-------------------------------------------------------------------------------------", "QRTransactionID,QRID,TID,TransactionAmount,Currency,TransactionStatus,TransactionTime,MDRCharge,TaxOnMDR,NetReceived,BatchNumber" ] ++ Enum.map(transactions, fn tx -> "#{tx.qr_transaction_id},#{tx.uhid},#{tx.device_id},#{tx.amount},#{tx.currency},#{tx.status},#{tx.time},#{tx.mdr_charge},#{tx.tax_on_mdr},#{tx.net_received},#{tx.batch_number}" end) ++ [ "#-------------------------------------------------------------------------------------", "#TotalRowCount,#{settlement.total_transaction_count}", "#Checksum,#{settlement.checksum}", "#FileGeneratedBy,AaniQR System", "#EndOfFile" ]) |> Enum.join("\n") end end