defmodule CFONB do @moduledoc """ Parser for CFONB bank files (French banking standard, *Comité Français d'Organisation et de Normalisation Bancaires*). This first version handles the **120-character account statement** (relevé de compte), made of four fixed-width record types: * `01` — previous balance (ancien solde) * `04` — operation / movement (mouvement) * `05` — operation detail (complément), optional and repeatable * `07` — new balance (nouveau solde) See `FORMAT.md` for the exact field layout, and `CFONB.Statement` / `CFONB.Operation` for the returned structs. ## Example {:ok, [statement | _]} = CFONB.parse(File.read!("releve.txt")) statement.from_balance #=> #Decimal<-190.40> Inspired by the [Ruby `cfonb` gem](https://github.com/pennylane-hq/cfonb) (MIT). Reimplemented from the official CFONB specification, with a compatible data model. """ alias CFONB.{Operation, Statement} @record_size 120 # Last character of a 14-char amount field carries the units digit and the # sign (CFONB/AFB "montant signé" encoding). @amount_specifiers %{ "{" => {1, 0}, "A" => {1, 1}, "B" => {1, 2}, "C" => {1, 3}, "D" => {1, 4}, "E" => {1, 5}, "F" => {1, 6}, "G" => {1, 7}, "H" => {1, 8}, "I" => {1, 9}, "}" => {-1, 0}, "J" => {-1, 1}, "K" => {-1, 2}, "L" => {-1, 3}, "M" => {-1, 4}, "N" => {-1, 5}, "O" => {-1, 6}, "P" => {-1, 7}, "Q" => {-1, 8}, "R" => {-1, 9} } @doc """ Parses the content of a CFONB 120 file into a list of `CFONB.Statement`. Returns `{:ok, statements}` or `{:error, reason}`. ## Options * `:optimistic` (default `false`) — when `true`, invalid records are skipped instead of aborting, and whatever statements could be built are returned. """ @spec parse(binary, keyword) :: {:ok, [Statement.t()]} | {:error, term} def parse(input, opts \\ []) when is_binary(input) do input |> records() |> reduce(%{statements: [], statement: nil, operation: nil}, optimistic?(opts)) end @doc """ Parses a standalone operation (`04` and its `05` details, without a surrounding statement) into a single `CFONB.Operation`. Returns `{:ok, operation}` (or `{:ok, nil}` if the input has none), or `{:error, reason}`. Accepts the same `:optimistic` option as `parse/2`. """ @spec parse_operation(binary, keyword) :: {:ok, Operation.t() | nil} | {:error, term} def parse_operation(input, opts \\ []) when is_binary(input) do input |> records() |> reduce_operation(nil, optimistic?(opts)) end @doc """ Same as `parse/2` but raises `ArgumentError` on invalid input. """ @spec parse!(binary, keyword) :: [Statement.t()] def parse!(input, opts \\ []) do case parse(input, opts) do {:ok, statements} -> statements {:error, reason} -> raise ArgumentError, "invalid CFONB input: #{inspect(reason)}" end end @doc """ Same as `parse_operation/2` but raises `ArgumentError` on invalid input. """ @spec parse_operation!(binary, keyword) :: Operation.t() | nil def parse_operation!(input, opts \\ []) do case parse_operation(input, opts) do {:ok, operation} -> operation {:error, reason} -> raise ArgumentError, "invalid CFONB operation: #{inspect(reason)}" end end defp optimistic?(opts), do: Keyword.get(opts, :optimistic, false) ## ---------------------------------------------------------------------------- ## Splitting into 120-char records (padding-tolerant) ## ---------------------------------------------------------------------------- defp records(input) do input |> String.split(["\r\n", "\n", "\r"]) |> Enum.flat_map(&to_records/1) end # Blank lines are ignored. A single record shorter than 120 chars (trailing # padding spaces stripped by some producers) is right-padded back to 120. # A line that concatenates several records is split every 120 chars. defp to_records(line) do cond do String.trim(line) == "" -> [] byte_size(line) <= @record_size -> [pad(line)] true -> for <>, do: record end end defp pad(line) when byte_size(line) < @record_size do line <> String.duplicate(" ", @record_size - byte_size(line)) end defp pad(line), do: line ## ---------------------------------------------------------------------------- ## Stateful reduction over records ## ---------------------------------------------------------------------------- # End of input: return completed statements. Any statement left open (no `07`) # is dropped, matching the reference implementation. defp reduce([], %{statements: acc}, _optimistic), do: {:ok, Enum.reverse(acc)} defp reduce([record | rest], state, optimistic) do result = with {:ok, parsed} <- parse_record(record) do step(parsed, state) end case result do {:ok, state} -> reduce(rest, state, optimistic) {:error, _reason} when optimistic -> reduce(rest, state, optimistic) {:error, _reason} = error -> error end end defp reduce_operation([], operation, _optimistic), do: {:ok, operation} defp reduce_operation([record | rest], operation, optimistic) do result = with {:ok, parsed} <- parse_record(record) do operation_step(parsed, operation) end case result do {:ok, operation} -> reduce_operation(rest, operation, optimistic) {:error, _reason} when optimistic -> reduce_operation(rest, operation, optimistic) {:error, _reason} = error -> error end end defp operation_step({:operation, _}, %Operation{}), do: {:error, :operation_already_defined} defp operation_step({:operation, fields}, nil), do: {:ok, Operation.new(fields)} defp operation_step({:detail, _}, nil), do: {:error, :detail_outside_operation} defp operation_step({:detail, detail}, operation), do: {:ok, Operation.add_detail(operation, detail)} defp operation_step({_kind, _}, _operation), do: {:error, :unhandled_line_code} defp step({:previous_balance, fields}, %{statement: nil} = state) do {:ok, %{state | statement: Statement.new(fields), operation: nil}} end defp step({:previous_balance, _}, _state), do: {:error, :unterminated_statement} defp step({:operation, _}, %{statement: nil}), do: {:error, :operation_outside_statement} defp step({:operation, fields}, state) do statement = push_operation(state.statement, state.operation) {:ok, %{state | statement: statement, operation: Operation.new(fields)}} end defp step({:detail, _}, %{operation: nil}), do: {:error, :detail_outside_operation} defp step({:detail, detail}, state) do {:ok, %{state | operation: Operation.add_detail(state.operation, detail)}} end defp step({:new_balance, _}, %{statement: nil}), do: {:error, :new_balance_outside_statement} defp step({:new_balance, fields}, state) do statement = state.statement |> push_operation(state.operation) |> Statement.close(fields) {:ok, %{state | statements: [statement | state.statements], statement: nil, operation: nil}} end defp push_operation(statement, nil), do: statement defp push_operation(statement, %Operation{} = operation) do %{statement | operations: statement.operations ++ [operation]} end ## ---------------------------------------------------------------------------- ## Record parsing (binary pattern matching on the fixed 120-char layout) ## ---------------------------------------------------------------------------- defp parse_record(<> = record) do parse_record(code, record) end defp parse_record("01", record), do: balance(:previous_balance, record) defp parse_record("07", record), do: balance(:new_balance, record) defp parse_record("04", record), do: operation(record) defp parse_record("05", record), do: detail(record) defp parse_record(code, _record), do: {:error, {:invalid_record_code, code}} # Records 01 and 07 share the same layout. defp balance(kind, record) do <<_code::binary-size(2), bank::binary-size(5), _internal::binary-size(4), branch::binary-size(5), currency::binary-size(3), scale::binary-size(1), _reserved::binary-size(1), account::binary-size(11), _reserved2::binary-size(2), date::binary-size(6), _reserved3::binary-size(50), amount::binary-size(14), _reserved4::binary-size(16)>> = record scale = to_int(scale) with {:ok, date} <- decode_date(date), {:ok, amount} <- decode_amount(amount, scale) do {:ok, {kind, %{ bank: trim(bank), branch: trim(branch), currency: trim(currency), account: trim(account), date: date, amount: amount, raw: record }}} end end defp operation(record) do <<_code::binary-size(2), bank::binary-size(5), internal::binary-size(4), branch::binary-size(5), currency::binary-size(3), scale::binary-size(1), _reserved::binary-size(1), account::binary-size(11), interbank::binary-size(2), date::binary-size(6), rejection::binary-size(2), value_date::binary-size(6), label::binary-size(32), _reserved2::binary-size(1), number::binary-size(7), exoneration::binary-size(1), unavailability::binary-size(1), amount::binary-size(14), reference::binary-size(16)>> = record scale = to_int(scale) with {:ok, date} <- decode_date(date), {:ok, value_date} <- decode_date(value_date), {:ok, amount} <- decode_amount(amount, scale) do {:ok, {:operation, %{ bank: trim(bank), branch: trim(branch), currency: trim(currency), account: trim(account), internal_code: trim(internal), interbank_code: trim(interbank), rejection_code: trim(rejection), date: date, value_date: value_date, label: trim(label), number: to_int(number), exoneration_code: trim(exoneration), unavailability_code: trim(unavailability), amount: amount, reference: trim(reference), raw: record }}} end end defp detail(record) do <<_code::binary-size(2), _bank::binary-size(5), _internal::binary-size(4), _branch::binary-size(5), _currency::binary-size(3), _scale::binary-size(1), _reserved::binary-size(1), _account::binary-size(11), _interbank::binary-size(2), _date::binary-size(6), _reserved2::binary-size(5), qualifier::binary-size(3), info::binary-size(70), _reserved3::binary-size(2)>> = record # `info` is kept raw (70 chars): qualifier-specific decoders slice it by # position. See `CFONB.Operation.Details`. {:ok, {:detail, %{qualifier: trim(qualifier), info: info, raw: record}}} end ## ---------------------------------------------------------------------------- ## Field decoding ## ---------------------------------------------------------------------------- # Amount: 13 digits + 1 sign/units specifier, scaled by the record's decimals. defp decode_amount(<>, scale) do case @amount_specifiers do %{^specifier => {sign, last_digit}} -> coefficient = to_int(digits) * 10 + last_digit {:ok, %Decimal{sign: sign, coef: coefficient, exp: -scale}} _ -> {:error, {:invalid_amount_specifier, specifier}} end end # Date: JJMMAA. Two-digit year pivots at 60 (>60 => 19xx, else 20xx), matching # the reference implementation. A blank field is a valid absence (`nil`). defp decode_date(<<_::binary-size(6)>> = raw) do case String.trim(raw) do "" -> {:ok, nil} _ -> <> = raw yy = String.to_integer(year) full_year = if yy > 60, do: 1900 + yy, else: 2000 + yy case Date.new(full_year, String.to_integer(month), String.to_integer(day)) do {:ok, date} -> {:ok, date} {:error, _} -> {:error, {:invalid_date, raw}} end end end defp trim(binary), do: String.trim(binary) defp to_int(binary) do case String.trim(binary) do "" -> 0 digits -> String.to_integer(digits) end end end