defmodule AbaValidator do import __MODULE__.Utils @moduledoc """ Documentation for `AbaValidator`. """ @doc """ Reads a file, validates it as an ABA file and returns the processed contents. ## Examples iex> AbaValidator.process_aba_file("./test/helper") {:error, :file_doesnt_exists} iex> AbaValidator.process_aba_file("./test/helper/test.txt") {:error, :file_doesnt_exists} iex> AbaValidator.process_aba_file("./test/helper/test.aba") [ {:descriptive_record, :ok, {"01", "CBA", "test ", "301500", "221212121227", "121222"}}, {:detail_record, :ok, {"040-440", "123456", :blank, :externally_initiated_credit, 35389, "4dd86..4936b", "Bank cashback", "040-404", "12345678", "test", 0}}, {:detail_record, :ok, {"040-404", "12345678", :blank, :externally_initiated_debit, 35389, "Records", "Fake payment", "040-404", "12345678", "test", 0}}, {:file_total_record, :output, {0, 35389, 35389, 2}} ] """ def process_aba_file(file_path) when is_binary(file_path) do unless not File.dir?(file_path) and File.exists?(file_path) do {:error, :file_doesnt_exists} else File.stream!(file_path) |> Stream.with_index(1) |> Stream.transform({0, 0, 0, false, false}, fn {line, index}, {description_count, detail_count, file_count, description_proceed?, file_record_processed?} -> determine_record_type(line) |> case do :error -> {:halt, :error} :description -> if description_proceed? do raise "Line #{index}: Multiple description records in file" end {process_aba_contents(line, index), {description_count + 1, detail_count, file_count, true, file_record_processed?}} :detail -> {process_aba_contents(line, index), {description_count, detail_count + 1, file_count, description_proceed?, file_record_processed?}} :file_total -> if file_record_processed? do raise "Line #{index}: Multiple file total records in file" end {process_aba_contents(line, detail_count), {description_count, detail_count, file_count + 1, description_proceed?, true}} end end) |> Enum.to_list() end end defp determine_record_type("0" <> _), do: :description defp determine_record_type("1" <> _), do: :detail defp determine_record_type("7" <> _), do: :file_total defp determine_record_type(_), do: :error defp process_aba_contents("0" <> _ = line, _index) do String.trim(line, "\n") |> get_descriptive_record() |> case do {:ok, output} -> [{:descriptive_record, :ok, output}] {:error, error} -> [{:descriptive_record, :error, error}] end end defp process_aba_contents("1" <> _ = line, index) do String.trim(line, "\n") |> get_detail_record() |> case do {:ok, output} -> [{:detail_record, :ok, output}] {:error, error} -> [{:detail_record, :error, error, index}] end end defp process_aba_contents("7" <> _ = line, count) do String.trim(line, "\n") |> get_file_total_record(count) |> case do {:ok, output} -> [{:file_total_record, :output, output}] {:error, error} -> [{:file_total_record, :error, error}] end end defp process_aba_contents(_line, _count) do :error end @doc """ Get the entries as part of the descriptive record ## Examples iex> AbaValidator.get_descriptive_record(1) {:error, :invalid_input} iex> AbaValidator.get_descriptive_record("11") {:error, :incorrect_length} iex> AbaValidator.get_descriptive_record("01") {:error, :incorrect_length} iex> AbaValidator.get_descriptive_record("1 01CBA test 301500221212121227121222 ") {:error, :incorrect_starting_code} iex> AbaValidator.get_descriptive_record("0 CBA test 301500221212121227121222 ") {:error, {:invalid_format, [:reel_sequence_number]}} iex> AbaValidator.get_descriptive_record("0 01CBA test 301500221212121227121222 ") {:ok, {"01", "CBA", "test ", "301500", "221212121227", "121222"}} """ def get_descriptive_record(entry) when not is_binary(entry) do {:error, :invalid_input} end def get_descriptive_record(entry) do if not correct_length?(entry, 120) do {:error, :incorrect_length} else {code, entry} = String.split_at(entry, 1) if code != "0" do {:error, :incorrect_starting_code} else {first_blank, entry} = String.split_at(entry, 17) {reel_sequence_number, entry} = String.split_at(entry, 2) {bank_abbreviation, entry} = String.split_at(entry, 3) {mid_blank, entry} = String.split_at(entry, 7) {user_preferred_specification, entry} = String.split_at(entry, 26) {user_id_number, entry} = String.split_at(entry, 6) {description, entry} = String.split_at(entry, 12) {date, last_blank} = String.split_at(entry, 6) with correct_first_blanks <- correct_length?(first_blank, 17), correct_mid_blanks <- correct_length?(mid_blank, 7), correct_last_blanks <- correct_length?(last_blank, 40), reel_sequence_number_empty? <- string_empty?(reel_sequence_number), bank_abbreviation_empty? <- string_empty?(bank_abbreviation), user_preferred_specification_empty? <- string_empty?(user_preferred_specification), user_id_number_empty? <- string_empty?(user_id_number), description_empty? <- string_empty?(description), valid_date <- valid_date?(date), date_empty? <- string_empty?(date) do errors = [] errors = if not correct_first_blanks, do: errors ++ [:first_blank], else: errors errors = if not correct_last_blanks, do: errors ++ [:last_blank], else: errors errors = if not correct_mid_blanks, do: errors ++ [:mid_blank], else: errors errors = if reel_sequence_number_empty?, do: errors ++ [:reel_sequence_number], else: errors errors = if bank_abbreviation_empty?, do: errors ++ [:bank_abbreviation], else: errors errors = if user_preferred_specification_empty?, do: errors ++ [:user_preferred_specification], else: errors errors = if user_id_number_empty?, do: errors ++ [:user_id_number], else: errors errors = if description_empty?, do: errors ++ [:description], else: errors errors = if not valid_date or date_empty?, do: errors ++ [:date], else: errors if(length(errors) > 0) do {:error, {:invalid_format, errors}} else {:ok, {reel_sequence_number, bank_abbreviation, user_preferred_specification, user_id_number, description, date}} end end end end end @doc """ Get the entries as part of the file total record ## Examples iex> AbaValidator.get_file_total_record(1) {:error, :invalid_input} iex> AbaValidator.get_file_total_record("11") {:error, :incorrect_length} iex> AbaValidator.get_file_total_record("01") {:error, :incorrect_length} iex> AbaValidator.get_file_total_record("1 01CBA test 301500221212121227121222 ") {:error, :incorrect_starting_code} iex> AbaValidator.get_file_total_record("7999 999 000000000000000353890000035388 000000 ") {:error, {:invalid_format, [:bsb_filler, :net_total_mismatch ]}} iex> AbaValidator.get_file_total_record("7 ") {:error, {:invalid_format, [:bsb_filler, :net_total, :total_credit, :total_debit, :record_count]}} iex> AbaValidator.get_file_total_record("7999 999 000000000000000353890000035388 000002 ") {:error, {:invalid_format, [:bsb_filler, :net_total_mismatch, :records_mismatch]}} iex> AbaValidator.get_file_total_record("7999-999 000000000000000353890000035389 000000 ") {:ok, {0, 35389, 35389, 0}} """ def get_file_total_record(entry, records \\ 0) def get_file_total_record(entry, _records) when not is_binary(entry) do {:error, :invalid_input} end def get_file_total_record(entry, records) when is_number(records) do if not correct_length?(entry, 120) do {:error, :incorrect_length} else {code, entry} = String.split_at(entry, 1) if code != "7" do {:error, :incorrect_starting_code} else {bsb_filler, entry} = String.split_at(entry, 7) {first_blank, entry} = String.split_at(entry, 12) {net_total, entry} = String.split_at(entry, 10) {total_credit, entry} = String.split_at(entry, 10) {total_debit, entry} = String.split_at(entry, 10) {mid_blank, entry} = String.split_at(entry, 24) {record_count, last_blank} = String.split_at(entry, 6) errors = [] errors = if bsb_filler !== "999-999", do: errors ++ [:bsb_filler], else: errors errors = if not correct_length?(first_blank, 12), do: errors ++ [:first_blank], else: errors errors = if not correct_length?(last_blank, 40), do: errors ++ [:last_blank], else: errors errors = if not correct_length?(mid_blank, 24), do: errors ++ [:mid_blank], else: errors # TODO Validate amount is positve errors = if string_empty?(net_total), do: errors ++ [:net_total], else: errors errors = if string_empty?(total_credit), do: errors ++ [:total_credit], else: errors errors = if string_empty?(total_debit), do: errors ++ [:total_debit], else: errors errors = if string_empty?(record_count), do: errors ++ [:record_count], else: errors errors = unless string_empty?(net_total) and string_empty?(total_credit) and string_empty?(total_debit) do net_amount = String.to_integer(net_total) credit_amount = String.to_integer(total_credit) debit_amount = String.to_integer(total_debit) if net_amount !== credit_amount - debit_amount, do: errors ++ [:net_total_mismatch], else: errors else errors end # TODO Validate total amount from detail match debit/credit errors = unless string_empty?(record_count) do if records !== String.to_integer(record_count), do: errors ++ [:records_mismatch], else: errors else errors end if(length(errors) > 0) do {:error, {:invalid_format, errors}} else {:ok, {String.to_integer(net_total), String.to_integer(total_credit), String.to_integer(total_debit), String.to_integer(record_count)}} end end end end @doc """ Get the entries as part of the detail record ## Examples iex> AbaValidator.get_detail_record(1) {:error, :invalid_input} iex> AbaValidator.get_detail_record("11") {:error, :incorrect_length} iex> AbaValidator.get_detail_record("01") {:error, :incorrect_length} iex> AbaValidator.get_detail_record("1032 898 12345678 130000035389money Batch payment 040 404 12345678test 00000000") {:error, {:invalid_format, [:bsb,:trace_record]}} iex> AbaValidator.get_detail_record("1 ") {:error, {:invalid_format, [:bsb, :account_number, :transasction_code, :amount, :account_name, :reference, :trace_record, :trace_account_number, :remitter, :withheld_tax]}} iex> AbaValidator.get_detail_record("7999 999 000000000000000353890000035388 000002 ") {:error, :incorrect_starting_code} iex> AbaValidator.get_detail_record("1032-898 12345678 130000035389 money Batch payment 040-404 12345678 test 00000000") {:ok, {"032-898", "12345678", :blank, :externally_initiated_debit, 35389, " money", " Batch payment","040-404", "12345678", " test", 0}} iex> AbaValidator.get_detail_record("1032-8980-2345678N130000035389money Batch payment 040-404 12345678test 00000000") {:ok, {"032-898", "0-2345678", :new_bank, :externally_initiated_debit, 35389, "money", "Batch payment","040-404", "12345678", "test", 0}} """ def get_detail_record(entry) when not is_binary(entry) do {:error, :invalid_input} end def get_detail_record(entry) do if not correct_length?(entry, 120) do {:error, :incorrect_length} else {code, entry} = String.split_at(entry, 1) if code != "1" do {:error, :incorrect_starting_code} else {bsb, entry} = String.split_at(entry, 7) {account_number, entry} = String.split_at(entry, 9) {indicator, entry} = String.split_at(entry, 1) {transasction_code, entry} = String.split_at(entry, 2) {amount, entry} = String.split_at(entry, 10) {account_name, entry} = String.split_at(entry, 32) {reference, entry} = String.split_at(entry, 18) {trace_record, entry} = String.split_at(entry, 7) {trace_account_number, entry} = String.split_at(entry, 9) {remitter_name, withheld_tax} = String.split_at(entry, 16) reference = String.trim_trailing(reference) trace_account_number = String.trim_leading(trace_account_number) remitter_name = String.trim_trailing(remitter_name) account_number = String.trim_leading(account_number) account_name = String.trim_trailing(account_name) errors = [] errors = if not valid_bsb?(bsb), do: errors ++ [:bsb], else: errors errors = if string_empty?(account_number), do: errors ++ [:account_number], else: errors errors = if get_indicator_code(indicator) == :error, do: errors ++ [:indicator], else: errors errors = if get_transaction_code(transasction_code) == :error, do: errors ++ [:transasction_code], else: errors errors = if Integer.parse(amount) == :error, do: errors ++ [:amount], else: errors errors = if string_empty?(account_name), do: errors ++ [:account_name], else: errors errors = if string_empty?(reference), do: errors ++ [:reference], else: errors errors = if not valid_bsb?(trace_record), do: errors ++ [:trace_record], else: errors errors = if string_empty?(trace_account_number), do: errors ++ [:trace_account_number], else: errors errors = if string_empty?(remitter_name), do: errors ++ [:remitter], else: errors errors = if Integer.parse(withheld_tax) == :error, do: errors ++ [:withheld_tax], else: errors if(length(errors) > 0) do {:error, {:invalid_format, errors}} else {:ok, {bsb, account_number, get_indicator_code(indicator), get_transaction_code(transasction_code), String.to_integer(amount), account_name, reference, trace_record, trace_account_number, remitter_name, String.to_integer(withheld_tax)}} end end end end defp get_transaction_code("13"), do: :externally_initiated_debit defp get_transaction_code("50"), do: :externally_initiated_credit defp get_transaction_code("51"), do: :australian_government_security_interest defp get_transaction_code("52"), do: :family_allowance defp get_transaction_code("53"), do: :pay defp get_transaction_code("54"), do: :pension defp get_transaction_code("55"), do: :allotment defp get_transaction_code("56"), do: :dividend defp get_transaction_code("57"), do: :debenture_note_interest defp get_transaction_code(_), do: :error defp get_indicator_code("N"), do: :new_bank defp get_indicator_code("W"), do: :dividend_resident_country_double_tax defp get_indicator_code("X"), do: :dividend_non_resident defp get_indicator_code("Y"), do: :interest_non_residents defp get_indicator_code(" "), do: :blank defp get_indicator_code(_), do: :error @spec get_transaction_code_description(String.t() | integer() | atom()) :: :error | String.t() @doc """ Get a description for a given transaction code. See [here](https://www.cemtexaba.com/aba-format/cemtex-aba-file-format-details) for the possible transaction code The following atoms are valid inputs: - :allotment - :australian_government_security_interest - :debenture_note_interest - :dividend - :error - :externally_initiated_credit - :externally_initiated_debit - :family_allowance - :pay - :pension ## Examples iex> AbaValidator.get_transaction_code_description("11") :error iex> AbaValidator.get_transaction_code_description(53) "Pay" iex> AbaValidator.get_transaction_code_description(:australian_government_security_interest) "Australian Government Security Interest" """ def get_transaction_code_description(13), do: "Externally initiated debit items" def get_transaction_code_description("13"), do: "Externally initiated debit items" def get_transaction_code_description(:externally_initiated_debit), do: "Externally initiated debit items" def get_transaction_code_description(50), do: "Externally initiated credit items with the exception of those bearing Transaction Codes" def get_transaction_code_description("50"), do: "Externally initiated credit items with the exception of those bearing Transaction Codes" def get_transaction_code_description(:externally_initiated_credit), do: "Externally initiated credit items with the exception of those bearing Transaction Codes" def get_transaction_code_description(51), do: "Australian Government Security Interest" def get_transaction_code_description("51"), do: "Australian Government Security Interest" def get_transaction_code_description(:australian_government_security_interest), do: "Australian Government Security Interest" def get_transaction_code_description(52), do: "Family Allowance" def get_transaction_code_description("52"), do: "Family Allowance" def get_transaction_code_description(:family_allowance), do: "Family Allowance" def get_transaction_code_description(53), do: "Pay" def get_transaction_code_description("53"), do: "Pay" def get_transaction_code_description(:pay), do: "Pay" def get_transaction_code_description(54), do: "Pension" def get_transaction_code_description("54"), do: "Pension" def get_transaction_code_description(:pension), do: "Pension" def get_transaction_code_description(55), do: "Allotment" def get_transaction_code_description("55"), do: "Allotment" def get_transaction_code_description(:allotment), do: "Allotment" def get_transaction_code_description(56), do: "Dividend" def get_transaction_code_description("56"), do: "Dividend" def get_transaction_code_description(:dividend), do: "Dividend" def get_transaction_code_description(57), do: "Debenture/Note Interest" def get_transaction_code_description("57"), do: "Debenture/Note Interest" def get_transaction_code_description(:debenture_note_interest), do: "Debenture/Note Interest" def get_transaction_code_description(_), do: :error end