From 0f637899b03e3d2f4ecd8f58748cf844389faf75 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Sun, 3 Dec 2023 07:54:31 +0800 Subject: [PATCH 01/32] create Account create/1 function and compare it with existing create/5 function --- lib/bookkeeping/core/account.ex | 70 ++++++++++++++++++- .../bookkeeping/core/account_benchmark.exs | 29 ++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 test/benchmark/bookkeeping/core/account_benchmark.exs diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index d32a0ab..e2d117f 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -7,7 +7,6 @@ defmodule Bookkeeping.Core.Account do alias Bookkeeping.Core.AuditLog @type t :: %__MODULE__{ - id: UUID.t(), code: account_code(), name: String.t(), description: String.t(), @@ -18,8 +17,9 @@ defmodule Bookkeeping.Core.Account do @type account_code :: String.t() - defstruct id: UUID.uuid4(), - code: "", + @account_classifications ~w(asset liability equity revenue expense gain loss contra_asset contra_liability contra_equity contra_revenue contra_expense contra_gain contra_loss) + + defstruct code: "", name: "", description: "", classification: nil, @@ -96,6 +96,70 @@ defmodule Bookkeeping.Core.Account do end end + def create(params) do + params |> check_fields() |> transform_params() |> maybe_create_account() + end + + defp check_fields(params) when is_map(params) do + fields = [:code, :name, :description, :classification, :audit_details, :active] + if Enum.all?(fields, &Map.has_key?(params, &1)), do: params, else: {:error, :invalid_params} + end + + defp check_fields(_params), do: {:error, :invalid_params} + + defp transform_params(params) when is_map(params), + do: Enum.reduce(params, %{params: %{}, errors: []}, &validate_field/2) + + defp transform_params(_params), do: {:error, :invalid_params} + + defp maybe_create_account(%{params: params, errors: []}), do: {:ok, struct(__MODULE__, params)} + defp maybe_create_account(%{errors: errors}), do: List.first(errors) + defp maybe_create_account(_params), do: {:error, :invalid_params} + + defp validate_field({key, value}, acc) when key in [:code, :name, :description] do + if is_binary(value) and value != "" do + params = Map.get(acc, :params, %{}) + updated_params = Map.put(params, key, value) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({:classification, value}, acc) do + if is_binary(value) and value in @account_classifications do + params = Map.get(acc, :params, %{}) + updated_params = Map.put(params, :classification, Map.get(accounts_classification(), value)) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({:audit_details, value}, acc) do + if is_map(value) do + params = Map.get(acc, :params, %{}) + current_audit_logs = Map.get(params, :audit_logs, []) + {:ok, audit_log} = AuditLog.create("account", "create", value) + updated_params = Map.put(params, :audit_logs, [audit_log | current_audit_logs]) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({:active, value}, acc) do + if is_boolean(value) do + params = Map.get(acc, :params, %{}) + updated_params = Map.put(params, :active, value) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({_key, _value}, acc), do: acc + @doc """ Updates an account struct. diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs new file mode 100644 index 0000000..9fc45b1 --- /dev/null +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -0,0 +1,29 @@ +defmodule Bookkeeping.Core.AccountBenchmark do + alias Bookkeeping.Core.Account + + Benchee.run(%{ + "create/1" => fn -> + Account.create(%{ + code: "1000", + name: "Cash 0", + type: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + }) + end, + "create/5" => fn -> + Account.create("1001", "Cash 1", "asset", "Cash and Cash Equivalents 1", %{}) + end, + "create/1 struct only" => fn -> + struct(%Account{}, %{ + code: "1000", + name: "Cash 0", + type: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + }) + end + }) +end From 84de9aff5f72d44b86d46b06d0fd67defc3ee28b Mon Sep 17 00:00:00 2001 From: jeryldev Date: Sun, 3 Dec 2023 10:32:47 +0800 Subject: [PATCH 02/32] optimize create/1 and add typedocs --- lib/bookkeeping/core/account.ex | 166 +++++++++++++++++++++----------- 1 file changed, 110 insertions(+), 56 deletions(-) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index e2d117f..73e7d4c 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -6,6 +6,9 @@ defmodule Bookkeeping.Core.Account do """ alias Bookkeeping.Core.AuditLog + @typedoc """ + t type is a struct that represents an account. + """ @type t :: %__MODULE__{ code: account_code(), name: String.t(), @@ -15,8 +18,23 @@ defmodule Bookkeeping.Core.Account do active: boolean() } + @typedoc """ + account_code type is a string that represents the code of an account. + """ @type account_code :: String.t() + @typedoc """ + create_params type is a map which represents the parameter used to create an account. + """ + @type create_params :: %{ + code: account_code(), + name: String.t(), + description: String.t(), + classification: String.t(), + audit_details: map(), + active: boolean() + } + @account_classifications ~w(asset liability equity revenue expense gain loss contra_asset contra_liability contra_equity contra_revenue contra_expense contra_gain contra_loss) defstruct code: "", @@ -96,70 +114,31 @@ defmodule Bookkeeping.Core.Account do end end - def create(params) do - params |> check_fields() |> transform_params() |> maybe_create_account() - end - - defp check_fields(params) when is_map(params) do - fields = [:code, :name, :description, :classification, :audit_details, :active] - if Enum.all?(fields, &Map.has_key?(params, &1)), do: params, else: {:error, :invalid_params} - end - - defp check_fields(_params), do: {:error, :invalid_params} + @doc """ + Creates a new account struct. - defp transform_params(params) when is_map(params), - do: Enum.reduce(params, %{params: %{}, errors: []}, &validate_field/2) + Arguments: + - params: The parameters of the account. The parameters must include the following fields: `code`, `name`, `description`, `classification`, `audit_details`, and `active`. - defp transform_params(_params), do: {:error, :invalid_params} + Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_params}` or `{:error, :invalid_field}`. - defp maybe_create_account(%{params: params, errors: []}), do: {:ok, struct(__MODULE__, params)} - defp maybe_create_account(%{errors: errors}), do: List.first(errors) - defp maybe_create_account(_params), do: {:error, :invalid_params} - - defp validate_field({key, value}, acc) when key in [:code, :name, :description] do - if is_binary(value) and value != "" do - params = Map.get(acc, :params, %{}) - updated_params = Map.put(params, key, value) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end - end + ## Examples - defp validate_field({:classification, value}, acc) do - if is_binary(value) and value in @account_classifications do - params = Map.get(acc, :params, %{}) - updated_params = Map.put(params, :classification, Map.get(accounts_classification(), value)) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end - end + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: %{}, active: true}) + {:ok, %Account{...}} - defp validate_field({:audit_details, value}, acc) do - if is_map(value) do - params = Map.get(acc, :params, %{}) - current_audit_logs = Map.get(params, :audit_logs, []) - {:ok, audit_log} = AuditLog.create("account", "create", value) - updated_params = Map.put(params, :audit_logs, [audit_log | current_audit_logs]) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end - end + iex> Account.create([]) + {:error, :invalid_params} - defp validate_field({:active, value}, acc) do - if is_boolean(value) do - params = Map.get(acc, :params, %{}) - updated_params = Map.put(params, :active, value) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end + iex> Account.create(%{code: "invalid", name: "invalid", classification: "invalid", description: nil, audit_details: false, active: %{}}) + {:error, :invalid_field} + """ + @spec create(create_params()) :: + {:ok, Account.t()} | {:error, :invalid_params} | {:error, :invalid_field} + def create(params) do + params |> check_fields() |> transform_params() |> maybe_create_account() end - defp validate_field({_key, _value}, acc), do: acc - @doc """ Updates an account struct. @@ -325,4 +304,79 @@ defmodule Bookkeeping.Core.Account do } } end + + defp check_fields(params) when is_map(params) do + fields = [:code, :name, :description, :classification, :audit_details, :active] + + if Enum.all?(fields, &Map.has_key?(params, &1)) and not Map.has_key?(params, :audit_logs), + do: params, + else: {:error, :invalid_params} + end + + defp check_fields(_params), do: {:error, :invalid_params} + + defp transform_params(params) when is_map(params) do + Enum.reduce(params, %{params: %{}, errors: []}, &validate_field/2) + end + + defp transform_params(_params), do: {:error, :invalid_params} + + defp validate_field({key, value}, acc) when key in [:code, :name, :description] do + if is_binary(value) and value != "" do + params = Map.get(acc, :params, %{}) + updated_params = Map.put(params, key, value) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({:classification, value}, acc) do + if is_binary(value) and value in @account_classifications do + params = Map.get(acc, :params, %{}) + updated_params = Map.put(params, :classification, Map.get(accounts_classification(), value)) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({:audit_details, value}, acc) do + if is_map(value) do + params = Map.get(acc, :params, %{}) + current_audit_logs = Map.get(params, :audit_logs, []) + {:ok, audit_log} = AuditLog.create("account", "create", value) + updated_params = Map.put(params, :audit_logs, [audit_log | current_audit_logs]) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({:audit_logs, value}, acc) do + if is_list(value) and Enum.all?(value, &is_struct(&1, AuditLog)) do + params = Map.get(acc, :params, %{}) + current_audit_logs = Map.get(params, :audit_logs, []) + updated_params = Map.put(params, :audit_logs, value ++ current_audit_logs) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({:active, value}, acc) do + if is_boolean(value) do + params = Map.get(acc, :params, %{}) + updated_params = Map.put(params, :active, value) + Map.put(acc, :params, updated_params) + else + Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) + end + end + + defp validate_field({_key, _value}, acc), do: acc + + defp maybe_create_account(%{params: params, errors: []}), do: {:ok, struct(__MODULE__, params)} + defp maybe_create_account(%{errors: errors}), do: List.first(errors) + defp maybe_create_account(_params), do: {:error, :invalid_params} end From b0b3a645855434cdf830ff29f71f417126dcd35a Mon Sep 17 00:00:00 2001 From: jeryldev Date: Sun, 3 Dec 2023 11:15:53 +0800 Subject: [PATCH 03/32] add validate account benchmark --- lib/bookkeeping/core/account.ex | 19 +++++++++-- .../bookkeeping/core/account_benchmark.exs | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 73e7d4c..289b32a 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -193,15 +193,13 @@ defmodule Bookkeeping.Core.Account do ## Examples - iex> {:ok, account} = Account.create("10_000", "cash", "asset") - iex> Account.validate_account(account) {:ok, %Account{...}} iex> Account.validate_account(%Account{}) {:error, :invalid_account} """ - @spec validate_account(map()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} + @spec validate_account(t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} def validate_account(account) do with true <- is_struct(account, __MODULE__), true <- is_binary(account.code) and account.code != "", @@ -216,6 +214,21 @@ defmodule Bookkeeping.Core.Account do end end + def validate(account) when is_struct(account, __MODULE__), do: {:ok, account} + + def validate(_account), do: {:error, :invalid_account} + + def validate2(account) when is_map(account) do + account_map = Map.from_struct(account) + + case transform_params(account_map) do + %{errors: []} -> {:ok, account} + %{errors: errors} -> List.first(errors) + end + end + + def validate2(_account), do: {:error, :invalid_account} + defp accounts_classification do %{ "asset" => %Classification{ diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs index 9fc45b1..5e3a10c 100644 --- a/test/benchmark/bookkeeping/core/account_benchmark.exs +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -26,4 +26,37 @@ defmodule Bookkeeping.Core.AccountBenchmark do }) end }) + + Benchee.run(%{ + "validate_account/1" => fn -> + Account.validate_account(%Account{ + code: "1003", + name: "Cash 3", + classification: "asset", + description: "Cash and Cash Equivalents 3", + audit_logs: [], + active: true + }) + end, + "validate/1" => fn -> + Account.validate(%Account{ + code: "1004", + name: "Cash 4", + classification: "asset", + description: "Cash and Cash Equivalents 4", + audit_logs: [], + active: true + }) + end, + "validate2/1" => fn -> + Account.validate2(%Account{ + code: "1005", + name: "Cash 5", + classification: "asset", + description: "Cash and Cash Equivalents 5", + audit_logs: [], + active: true + }) + end + }) end From 0b2cb23cdb7244f1a3f83638c94be86794cda96e Mon Sep 17 00:00:00 2001 From: jeryldev Date: Sun, 3 Dec 2023 16:19:28 +0800 Subject: [PATCH 04/32] optimize account validate/1 --- .../boundary/chart_of_accounts/server.ex | 2 +- lib/bookkeeping/core/account.ex | 40 +++++-------------- test/bookkeeping/core/account_test.exs | 4 +- 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/lib/bookkeeping/boundary/chart_of_accounts/server.ex b/lib/bookkeeping/boundary/chart_of_accounts/server.ex index c40b533..f7bd07f 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/server.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/server.ex @@ -349,7 +349,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Server do @impl true def handle_call({:update_account, account, attrs}, _from, accounts) do - with {:ok, account} <- Account.validate_account(account), + with {:ok, account} <- Account.validate(account), {:ok, updated_account} <- Account.update(account, attrs) do updated_accounts = accounts diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 289b32a..8abd6b9 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -193,42 +193,22 @@ defmodule Bookkeeping.Core.Account do ## Examples - iex> Account.validate_account(account) + iex> Account.validate(account) {:ok, %Account{...}} - iex> Account.validate_account(%Account{}) + iex> Account.validate(%Account{}) {:error, :invalid_account} """ - @spec validate_account(t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} - def validate_account(account) do - with true <- is_struct(account, __MODULE__), - true <- is_binary(account.code) and account.code != "", - true <- is_binary(account.name) and account.name != "", - true <- is_binary(account.description), - true <- is_boolean(account.active), - true <- is_list(account.audit_logs), - true <- is_struct(account.classification, Classification) do - {:ok, account} - else - _error -> {:error, :invalid_account} - end + @spec validate(t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} + def validate(account) do + if is_struct(account, __MODULE__) and is_binary(account.code) and account.code != "" and + is_binary(account.name) and account.name != "" and is_binary(account.description) and + is_boolean(account.active) and is_list(account.audit_logs) and + is_struct(account.classification, Classification), + do: {:ok, account}, + else: {:error, :invalid_account} end - def validate(account) when is_struct(account, __MODULE__), do: {:ok, account} - - def validate(_account), do: {:error, :invalid_account} - - def validate2(account) when is_map(account) do - account_map = Map.from_struct(account) - - case transform_params(account_map) do - %{errors: []} -> {:ok, account} - %{errors: errors} -> List.first(errors) - end - end - - def validate2(_account), do: {:error, :invalid_account} - defp accounts_classification do %{ "asset" => %Classification{ diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index 708421a..06dac3c 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -18,7 +18,7 @@ defmodule Bookkeeping.Core.AccountTest do assert new_account.classification.name == "Asset" assert new_account.classification.normal_balance == :debit - assert {:ok, _valid_account} = Account.validate_account(new_account) + assert {:ok, _valid_account} = Account.validate(new_account) end test "create account with description and active fields", %{details: details} do @@ -86,6 +86,6 @@ defmodule Bookkeeping.Core.AccountTest do end test "validate account" do - assert {:error, :invalid_account} = Account.validate_account(%Account{}) + assert {:error, :invalid_account} = Account.validate(%Account{}) end end From 31beba0386269800965d5394206d39b5351a823e Mon Sep 17 00:00:00 2001 From: jeryldev Date: Mon, 4 Dec 2023 10:51:03 +0800 Subject: [PATCH 05/32] create a sample self restoring ets --- lib/bookkeeping/boundary/sample/manager.ex | 41 +++++++++++++++++ lib/bookkeeping/boundary/sample/supervisor.ex | 29 ++++++++++++ lib/bookkeeping/boundary/sample/worker.ex | 46 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 lib/bookkeeping/boundary/sample/manager.ex create mode 100644 lib/bookkeeping/boundary/sample/supervisor.ex create mode 100644 lib/bookkeeping/boundary/sample/worker.ex diff --git a/lib/bookkeeping/boundary/sample/manager.ex b/lib/bookkeeping/boundary/sample/manager.ex new file mode 100644 index 0000000..35f91d1 --- /dev/null +++ b/lib/bookkeeping/boundary/sample/manager.ex @@ -0,0 +1,41 @@ +defmodule Bookkeeping.Boundary.Sample.Manager do + use GenServer + + alias Bookkeeping.Boundary.Sample.Worker + + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + Process.flag(:trap_exit, true) + worker = Process.whereis(Worker) + Process.link(worker) + table = :ets.new(:give_away, [:private]) + data = {:count, 0} + :ets.insert(table, data) + :ets.setopts(table, {:heir, self(), data}) + :ets.give_away(table, worker, data) + {:ok, table} + end + + def handle_info({:EXIT, _from, _reason}, table), do: {:noreply, table} + + def handle_info({:"ETS-TRANSFER", table, _pid, data}, _table) do + worker = wait_for_worker() + Process.link(worker) + :ets.give_away(table, worker, data) + {:noreply, table} + end + + def wait_for_worker() do + case Process.whereis(Worker) do + nil -> + Process.sleep(1) + wait_for_worker() + + pid -> + pid + end + end +end diff --git a/lib/bookkeeping/boundary/sample/supervisor.ex b/lib/bookkeeping/boundary/sample/supervisor.ex new file mode 100644 index 0000000..c64e81f --- /dev/null +++ b/lib/bookkeeping/boundary/sample/supervisor.ex @@ -0,0 +1,29 @@ +defmodule Bookkeeping.Boundary.Sample.Supervisor do + use Supervisor + + alias Bookkeeping.Boundary.Sample.Manager, as: SampleManager + alias Bookkeeping.Boundary.Sample.Worker, as: SampleWorker + + @type init_options_t :: list() + @type sup_flags_t :: map() + @type children_specs_t :: list(:supervisor.child_spec()) + + @spec start_link(init_options_t()) :: + {:ok, pid} | {:error, {:already_started, pid()} | {:shutdown, term()} | term()} + + def start_link(options \\ []) do + Supervisor.start_link(__MODULE__, :ok, options) + end + + @impl true + + @spec init(any()) :: {:ok, {sup_flags_t(), children_specs_t}} + def init(_init_arg) do + children = [ + {SampleWorker, [name: SampleWorker]}, + {SampleManager, %{}} + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/lib/bookkeeping/boundary/sample/worker.ex b/lib/bookkeeping/boundary/sample/worker.ex new file mode 100644 index 0000000..e736456 --- /dev/null +++ b/lib/bookkeeping/boundary/sample/worker.ex @@ -0,0 +1,46 @@ +defmodule Bookkeeping.Boundary.Sample.Worker do + use GenServer + + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + {:ok, nil} + end + + def handle_info({:"ETS-TRANSFER", table, _pid, _data}, _table) do + {:noreply, table} + end + + def handle_call({:get, key}, _from, table) do + case :ets.lookup(table, key) do + [] -> + {:reply, nil, table} + + [{_key, value}] -> + {:reply, value, table} + end + end + + def handle_call({:put, key, value}, _from, table) do + result = :ets.insert(table, {key, value}) + {:reply, result, table} + end + + def handle_cast(:die, table) do + {:stop, table, :killed} + end + + def get(key) do + GenServer.call(__MODULE__, {:get, key}) + end + + def put(key, value) do + GenServer.call(__MODULE__, {:put, key, value}) + end + + def die() do + GenServer.cast(__MODULE__, :die) + end +end From 539858c01c40730ed4bbeb6b22c3e9d87621c902 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Mon, 4 Dec 2023 10:51:32 +0800 Subject: [PATCH 06/32] rename the type to classification --- test/benchmark/bookkeeping/core/account_benchmark.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs index 5e3a10c..a59ddd8 100644 --- a/test/benchmark/bookkeeping/core/account_benchmark.exs +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -6,7 +6,7 @@ defmodule Bookkeeping.Core.AccountBenchmark do Account.create(%{ code: "1000", name: "Cash 0", - type: "asset", + classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true From 32b6b98a68143bdff1d7c1e81243672fd9b4b0e6 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Mon, 4 Dec 2023 11:05:09 +0800 Subject: [PATCH 07/32] create coa server 2 with benchmark --- .../boundary/chart_of_accounts/server2.ex | 63 +++++++++++++++++++ .../boundary/chart_of_accounts.exs | 61 ++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 lib/bookkeeping/boundary/chart_of_accounts/server2.ex create mode 100644 test/benchmark/bookkeeping/boundary/chart_of_accounts.exs diff --git a/lib/bookkeeping/boundary/chart_of_accounts/server2.ex b/lib/bookkeeping/boundary/chart_of_accounts/server2.ex new file mode 100644 index 0000000..e849a19 --- /dev/null +++ b/lib/bookkeeping/boundary/chart_of_accounts/server2.ex @@ -0,0 +1,63 @@ +defmodule Bookkeeping.Boundary.ChartOfAccounts.Server2 do + use GenServer + + alias Bookkeeping.Core.Account + + def start_link(_opts) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + def init(_init_arg) do + :ets.new(__MODULE__, [ + :named_table, + :public, + write_concurrency: true, + read_concurrency: true + ]) + + {:ok, nil} + end + + def create(params) do + code = Map.get(params, :code, "") + name = Map.get(params, :name, "") + + with {:error, :not_found} <- search_code(code), + {:error, :not_found} <- search_name(name), + {:ok, account} <- Account.create(params) do + :ets.insert(__MODULE__, {account.code, account.name, account}) + {:ok, account} + end + end + + def search_code(code) do + case :ets.lookup(__MODULE__, code) do + [{_, _, account}] -> {:ok, account} + [] -> {:error, :not_found} + end + end + + def search_name(name) do + result = :ets.match(__MODULE__, {:_, name, :"$1"}) |> List.flatten() + + if result == [], do: {:error, :not_found}, else: {:ok, result} + end + + def update(server \\ __MODULE__) do + GenServer.call(server, :update) + # case Account.update(params) do + # {:ok, account} -> + # :ets.insert(__MODULE__, {account.code, account.name, account}) + # {:ok, account} + + # {:error, _} -> + # {:error, :invalid_account} + # end + end + + def handle_call(:update, _from, state) do + raise "not implemented" + + {:noreply, state} + end +end diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts.exs new file mode 100644 index 0000000..438b9e2 --- /dev/null +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts.exs @@ -0,0 +1,61 @@ +defmodule Bookkeeping.Boundary.ChartOfAccounts do + alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer + alias Bookkeeping.Boundary.ChartOfAccounts.Server2, as: ChartOfAccountsServer2 + + ChartOfAccountsServer.start_link() + ChartOfAccountsServer2.start_link([]) + + Benchee.run(%{ + "COA Server create/5" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + ChartOfAccountsServer.create_account( + random_string, + random_string, + "asset", + "Cash and Cash Equivalents 0", + %{} + ) + end, + "COA Server2 create/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + params = %{ + code: random_string, + name: random_string, + classification: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + } + + ChartOfAccountsServer2.create(params) + end + }) + + Benchee.run(%{ + "COA Server find_account_by_code/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + ChartOfAccountsServer.find_account_by_code(random_string) + end, + "COA Server2 search_code/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + ChartOfAccountsServer2.search_code(random_string) + end + }) + + Benchee.run(%{ + "COA Server find_account_by_name/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + ChartOfAccountsServer.find_account_by_name(random_string) + end, + "COA Server2 search_name/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + ChartOfAccountsServer2.search_name(random_string) + end + }) +end From 19797f923a3fa6d2eeb8f4473e8fe083a384d9ed Mon Sep 17 00:00:00 2001 From: jeryldev Date: Tue, 5 Dec 2023 00:44:01 +0800 Subject: [PATCH 08/32] add benchmark when importing a file --- ...ts.exs => chart_of_accounts_benchmark.exs} | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) rename test/benchmark/bookkeeping/boundary/{chart_of_accounts.exs => chart_of_accounts_benchmark.exs} (57%) diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs similarity index 57% rename from test/benchmark/bookkeeping/boundary/chart_of_accounts.exs rename to test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs index 438b9e2..a7f3dad 100644 --- a/test/benchmark/bookkeeping/boundary/chart_of_accounts.exs +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs @@ -1,9 +1,12 @@ -defmodule Bookkeeping.Boundary.ChartOfAccounts do +defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer alias Bookkeeping.Boundary.ChartOfAccounts.Server2, as: ChartOfAccountsServer2 + alias Bookkeeping.Boundary.ChartOfAccounts2.Supervisor, as: ChartOfAccounts2Supervisor + alias Bookkeeping.Boundary.ChartOfAccounts2.Worker ChartOfAccountsServer.start_link() ChartOfAccountsServer2.start_link([]) + ChartOfAccounts2Supervisor.start_link() Benchee.run(%{ "COA Server create/5" => fn -> @@ -30,6 +33,26 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts do } ChartOfAccountsServer2.create(params) + end, + "COA Worker create/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + params = %{ + code: random_string, + name: random_string, + classification: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + } + + IO.inspect(random_string) + + result = Worker.create(params) + + IO.inspect(result) + + result end }) @@ -43,6 +66,11 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts do random_string = for _ <- 1..10, into: "", do: <> ChartOfAccountsServer2.search_code(random_string) + end, + "COA Worker search_code/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + Worker.search_code(random_string) end }) @@ -56,6 +84,20 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts do random_string = for _ <- 1..10, into: "", do: <> ChartOfAccountsServer2.search_name(random_string) + end, + "COA Worker search_name/1" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + Worker.search_name(random_string) + end + }) + + Benchee.run(%{ + "COA Server import_accounts/1" => fn -> + ChartOfAccountsServer.import_accounts("../../data/sample_chart_of_accounts.csv") + end, + "COA Worker import_file/1" => fn -> + Worker.import_file("../../data/sample_chart_of_accounts.csv") end }) end From cc4e2589f4579889a40886ca72dbb326140cdc17 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Tue, 5 Dec 2023 00:44:33 +0800 Subject: [PATCH 09/32] recreate chart of accounts boundary using GenServer ETS solution --- .../boundary/chart_of_accounts_2/manager.ex | 48 +++++ .../chart_of_accounts_2/supervisor.ex | 29 +++ .../boundary/chart_of_accounts_2/worker.ex | 168 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex create mode 100644 lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex create mode 100644 lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex new file mode 100644 index 0000000..18f4ec9 --- /dev/null +++ b/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex @@ -0,0 +1,48 @@ +defmodule Bookkeeping.Boundary.ChartOfAccounts2.Manager do + use GenServer + + alias Bookkeeping.Boundary.ChartOfAccounts2.Worker + + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + Process.flag(:trap_exit, true) + worker = Process.whereis(Worker) + Process.link(worker) + + table = + :ets.new(:give_away, [ + :private, + write_concurrency: true, + read_concurrency: true + ]) + + data = {:count, 0} + :ets.insert(table, data) + :ets.setopts(table, {:heir, self(), data}) + :ets.give_away(table, worker, data) + {:ok, table} + end + + def handle_info({:EXIT, _from, _reason}, table), do: {:noreply, table} + + def handle_info({:"ETS-TRANSFER", table, _pid, data}, _table) do + worker = wait_for_worker() + Process.link(worker) + :ets.give_away(table, worker, data) + {:noreply, table} + end + + def wait_for_worker() do + case Process.whereis(Worker) do + nil -> + Process.sleep(1) + wait_for_worker() + + pid -> + pid + end + end +end diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex new file mode 100644 index 0000000..f275288 --- /dev/null +++ b/lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex @@ -0,0 +1,29 @@ +defmodule Bookkeeping.Boundary.ChartOfAccounts2.Supervisor do + use Supervisor + + alias Bookkeeping.Boundary.ChartOfAccounts2.Worker + alias Bookkeeping.Boundary.ChartOfAccounts2.Manager + + @type init_options_t :: list() + @type sup_flags_t :: map() + @type children_specs_t :: list(:supervisor.child_spec()) + + @spec start_link(init_options_t()) :: + {:ok, pid} | {:error, {:already_started, pid()} | {:shutdown, term()} | term()} + + def start_link(options \\ []) do + Supervisor.start_link(__MODULE__, :ok, options) + end + + @impl true + + @spec init(any()) :: {:ok, {sup_flags_t(), children_specs_t}} + def init(_init_arg) do + children = [ + {Worker, [name: Worker]}, + {Manager, %{}} + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex new file mode 100644 index 0000000..78f4936 --- /dev/null +++ b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex @@ -0,0 +1,168 @@ +defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do + use GenServer + + alias Bookkeeping.Core.Account + alias NimbleCSV.RFC4180, as: CSV + + @spec start_link(any()) :: :ignore | {:error, any()} | {:ok, pid()} + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @spec create(Account.create_params()) :: + {:error, :invalid_params} + | {:error, :invalid_field} + | {:error, :already_exists} + | {:ok, Account.t()} + def create(params) do + GenServer.call(__MODULE__, {:create, params}) + end + + @spec import_file(String.t()) :: + {:error, :invalid_file} + | {:ok, + %{ + oks: list(Account.t()), + errors: + list(%{ + reason: :invalid_params | :invalid_field | :already_exists, + params: Account.create_params() + }) + }} + def import_file(file_path) do + file_path |> check_csv() |> read_csv() |> bulk_generate_params() |> bulk_create() + end + + @spec search_code(Account.account_code()) :: {:error, :not_found} | {:ok, Account.t()} + def search_code(code) do + GenServer.call(__MODULE__, {:search_code, code}) + end + + @spec search_name(String.t()) :: {:error, :not_found} | {:ok, Account.t()} + def search_name(name) do + GenServer.call(__MODULE__, {:search_name, name}) + end + + @spec init(any()) :: {:ok, nil} + def init(_) do + {:ok, nil} + end + + def handle_info({:"ETS-TRANSFER", table, _pid, _data}, _table) do + {:noreply, table} + end + + def handle_call({:create, params}, _from, table) do + result = create(table, params) + {:reply, result, table} + end + + def handle_call({:search_code, code}, _from, table) do + result = search_code(table, code) + {:reply, result, table} + end + + def handle_call({:search_name, name}, _from, table) do + result = search_name(table, name) + {:reply, result, table} + end + + defp create(table, params) do + with {:ok, params} <- check_similar_account(table, params), + {:ok, account} <- Account.create(params) do + :ets.insert(table, {account.code, account.name, account}) + {:ok, account} + end + end + + defp check_similar_account(table, params) do + code = Map.get(params, :code, "") + name = Map.get(params, :name, "") + + with {:error, :not_found} <- search_code(table, code), + {:error, :not_found} <- search_name(table, name) do + {:ok, params} + else + {:ok, _account} -> {:error, :already_exists} + end + end + + defp search_code(table, code) do + case :ets.lookup(table, code) do + [{_, _, account}] -> {:ok, account} + [] -> {:error, :not_found} + end + end + + defp search_name(table, name) do + result = :ets.match(table, {:_, name, :"$1"}) |> List.flatten() |> List.first() + if is_nil(result), do: {:error, :not_found}, else: {:ok, result} + end + + defp check_csv(path) when is_binary(path) do + file_path = Path.expand(path, __DIR__) + + if File.exists?(file_path), + do: {:ok, file_path}, + else: {:error, :invalid_file} + end + + defp check_csv(_path), do: {:error, :invalid_file} + + defp read_csv({:ok, path}) do + csv_inputs = + path + |> File.stream!() + |> CSV.parse_stream(skip_headers: false) + |> Stream.transform(nil, fn + headers, nil -> {[], headers} + row, headers -> {[Enum.zip(headers, row) |> Map.new()], headers} + end) + |> Enum.to_list() + + if csv_inputs == [], do: {:error, :invalid_file}, else: {:ok, csv_inputs} + end + + defp read_csv(error), do: error + + defp bulk_generate_params({:ok, csv}) do + Enum.reduce(csv, [], fn csv_item, acc -> + code = Map.get(csv_item, "Account Code") + name = Map.get(csv_item, "Account Name") + classification = Map.get(csv_item, "Account Type") + description = Map.get(csv_item, "Account Description", "") + + audit_details = + case csv_item |> Map.get("Audit Details", "{}") |> Jason.decode() do + {:ok, audit_details} -> audit_details + {:error, _} -> %{} + end + + params = %{ + code: code, + name: name, + classification: classification, + description: description, + audit_details: audit_details, + active: true + } + + [params | acc] + end) + end + + defp bulk_generate_params(error), do: error + + defp bulk_create([]), do: {:error, :invalid_file} + + defp bulk_create(params_list) when is_list(params_list) do + Enum.reduce(params_list, %{oks: [], errors: []}, fn params, acc -> + case create(params) do + {:ok, account} -> %{acc | oks: [account | acc.oks]} + {:error, reason} -> %{acc | errors: [%{reason: reason, params: params} | acc.errors]} + end + end) + end + + defp bulk_create(error), do: error +end From 0b9060b8443faf9953ae132a779bb03fdc320939 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Tue, 5 Dec 2023 00:59:40 +0800 Subject: [PATCH 10/32] remove server2 --- .../boundary/chart_of_accounts/server2.ex | 63 ------------------- .../boundary/chart_of_accounts_benchmark.exs | 26 -------- 2 files changed, 89 deletions(-) delete mode 100644 lib/bookkeeping/boundary/chart_of_accounts/server2.ex diff --git a/lib/bookkeeping/boundary/chart_of_accounts/server2.ex b/lib/bookkeeping/boundary/chart_of_accounts/server2.ex deleted file mode 100644 index e849a19..0000000 --- a/lib/bookkeeping/boundary/chart_of_accounts/server2.ex +++ /dev/null @@ -1,63 +0,0 @@ -defmodule Bookkeeping.Boundary.ChartOfAccounts.Server2 do - use GenServer - - alias Bookkeeping.Core.Account - - def start_link(_opts) do - GenServer.start_link(__MODULE__, nil, name: __MODULE__) - end - - def init(_init_arg) do - :ets.new(__MODULE__, [ - :named_table, - :public, - write_concurrency: true, - read_concurrency: true - ]) - - {:ok, nil} - end - - def create(params) do - code = Map.get(params, :code, "") - name = Map.get(params, :name, "") - - with {:error, :not_found} <- search_code(code), - {:error, :not_found} <- search_name(name), - {:ok, account} <- Account.create(params) do - :ets.insert(__MODULE__, {account.code, account.name, account}) - {:ok, account} - end - end - - def search_code(code) do - case :ets.lookup(__MODULE__, code) do - [{_, _, account}] -> {:ok, account} - [] -> {:error, :not_found} - end - end - - def search_name(name) do - result = :ets.match(__MODULE__, {:_, name, :"$1"}) |> List.flatten() - - if result == [], do: {:error, :not_found}, else: {:ok, result} - end - - def update(server \\ __MODULE__) do - GenServer.call(server, :update) - # case Account.update(params) do - # {:ok, account} -> - # :ets.insert(__MODULE__, {account.code, account.name, account}) - # {:ok, account} - - # {:error, _} -> - # {:error, :invalid_account} - # end - end - - def handle_call(:update, _from, state) do - raise "not implemented" - - {:noreply, state} - end -end diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs index a7f3dad..040f88c 100644 --- a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs @@ -1,11 +1,9 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer - alias Bookkeeping.Boundary.ChartOfAccounts.Server2, as: ChartOfAccountsServer2 alias Bookkeeping.Boundary.ChartOfAccounts2.Supervisor, as: ChartOfAccounts2Supervisor alias Bookkeeping.Boundary.ChartOfAccounts2.Worker ChartOfAccountsServer.start_link() - ChartOfAccountsServer2.start_link([]) ChartOfAccounts2Supervisor.start_link() Benchee.run(%{ @@ -20,20 +18,6 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do %{} ) end, - "COA Server2 create/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - params = %{ - code: random_string, - name: random_string, - classification: "asset", - description: "Cash and Cash Equivalents 0", - audit_details: %{}, - active: true - } - - ChartOfAccountsServer2.create(params) - end, "COA Worker create/1" => fn -> random_string = for _ <- 1..10, into: "", do: <> @@ -62,11 +46,6 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do ChartOfAccountsServer.find_account_by_code(random_string) end, - "COA Server2 search_code/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - ChartOfAccountsServer2.search_code(random_string) - end, "COA Worker search_code/1" => fn -> random_string = for _ <- 1..10, into: "", do: <> @@ -80,11 +59,6 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do ChartOfAccountsServer.find_account_by_name(random_string) end, - "COA Server2 search_name/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - ChartOfAccountsServer2.search_name(random_string) - end, "COA Worker search_name/1" => fn -> random_string = for _ <- 1..10, into: "", do: <> From aa0ce2546ba2dac3c9837ea3504ecc41fbf7669d Mon Sep 17 00:00:00 2001 From: jeryldev Date: Tue, 5 Dec 2023 19:50:12 +0800 Subject: [PATCH 11/32] replace the Account update/2 based on the benchmark --- lib/bookkeeping/core/account.ex | 140 ++++++++++-------- .../boundary/chart_of_accounts_benchmark.exs | 14 +- .../bookkeeping/core/account_benchmark.exs | 93 ++++++++---- 3 files changed, 150 insertions(+), 97 deletions(-) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 8abd6b9..96d59d0 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -133,10 +133,9 @@ defmodule Bookkeeping.Core.Account do iex> Account.create(%{code: "invalid", name: "invalid", classification: "invalid", description: nil, audit_details: false, active: %{}}) {:error, :invalid_field} """ - @spec create(create_params()) :: - {:ok, Account.t()} | {:error, :invalid_params} | {:error, :invalid_field} + @spec create(create_params()) :: {:ok, Account.t()} | {:error, :invalid_params | :invalid_field} def create(params) do - params |> check_fields() |> transform_params() |> maybe_create_account() + params |> validate_create_params() |> maybe_create() end @doc """ @@ -183,6 +182,12 @@ defmodule Bookkeeping.Core.Account do end end + @spec update2(any(), any()) :: + {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} + def update2(account, params) do + params |> validate_update_params(account) |> maybe_update(account) + end + @doc """ Validates an account struct. @@ -209,7 +214,7 @@ defmodule Bookkeeping.Core.Account do else: {:error, :invalid_account} end - defp accounts_classification do + def accounts_classification do %{ "asset" => %Classification{ name: "Asset", @@ -298,78 +303,87 @@ defmodule Bookkeeping.Core.Account do } end - defp check_fields(params) when is_map(params) do - fields = [:code, :name, :description, :classification, :audit_details, :active] - - if Enum.all?(fields, &Map.has_key?(params, &1)) and not Map.has_key?(params, :audit_logs), - do: params, - else: {:error, :invalid_params} + defp validate_create_params( + %{ + code: code, + name: name, + description: description, + classification: classification, + audit_details: audit_details, + active: active + } = params + ) do + if is_binary(code) and code != "" and is_binary(name) and name != "" and + is_binary(description) and is_binary(classification) and + classification in @account_classifications and + is_map(audit_details) and is_boolean(active), + do: params, + else: {:error, :invalid_field} end - defp check_fields(_params), do: {:error, :invalid_params} + defp validate_create_params(_params), do: {:error, :invalid_params} - defp transform_params(params) when is_map(params) do - Enum.reduce(params, %{params: %{}, errors: []}, &validate_field/2) + defp maybe_create(%{ + code: code, + name: name, + description: description, + classification: classification, + audit_details: audit_details, + active: active + }) do + {:ok, audit_log} = AuditLog.create("account", "create", audit_details) + classification = Map.get(accounts_classification(), classification) + + {:ok, + %__MODULE__{ + code: code, + name: name, + description: description, + classification: classification, + audit_logs: [audit_log], + active: active + }} end - defp transform_params(_params), do: {:error, :invalid_params} + defp maybe_create({:error, reason}), do: {:error, reason} - defp validate_field({key, value}, acc) when key in [:code, :name, :description] do - if is_binary(value) and value != "" do - params = Map.get(acc, :params, %{}) - updated_params = Map.put(params, key, value) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end - end + defp validate_update_params(params, _account) when not is_map(params), + do: {:error, :invalid_params} - defp validate_field({:classification, value}, acc) do - if is_binary(value) and value in @account_classifications do - params = Map.get(acc, :params, %{}) - updated_params = Map.put(params, :classification, Map.get(accounts_classification(), value)) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end - end + defp validate_update_params(_params, account) when not is_struct(account, __MODULE__), + do: {:error, :invalid_account} - defp validate_field({:audit_details, value}, acc) do - if is_map(value) do - params = Map.get(acc, :params, %{}) - current_audit_logs = Map.get(params, :audit_logs, []) - {:ok, audit_log} = AuditLog.create("account", "create", value) - updated_params = Map.put(params, :audit_logs, [audit_log | current_audit_logs]) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end + defp validate_update_params(%{audit_details: _audit_details} = params, account) do + Enum.reduce(params, %{}, fn {key, value}, acc -> + verify_update_field(key, value, acc, account) + end) end - defp validate_field({:audit_logs, value}, acc) do - if is_list(value) and Enum.all?(value, &is_struct(&1, AuditLog)) do - params = Map.get(acc, :params, %{}) - current_audit_logs = Map.get(params, :audit_logs, []) - updated_params = Map.put(params, :audit_logs, value ++ current_audit_logs) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end + defp validate_update_params(params, account) do + params + |> Map.put(:audit_details, %{}) + |> validate_update_params(account) end - defp validate_field({:active, value}, acc) do - if is_boolean(value) do - params = Map.get(acc, :params, %{}) - updated_params = Map.put(params, :active, value) - Map.put(acc, :params, updated_params) - else - Map.put(acc, :errors, [{:error, :invalid_field} | acc.errors]) - end + defp verify_update_field(key, value, acc, _account) + when key in [:name, :description] and + is_binary(value) and value != "", + do: Map.put(acc, key, value) + + defp verify_update_field(:active, value, acc, _account) when is_boolean(value), + do: Map.put(acc, :active, value) + + defp verify_update_field(:audit_details, value, acc, account) when is_map(value) do + {:ok, audit_log} = AuditLog.create("account", "update", value) + Map.put(acc, :audit_logs, [audit_log | account.audit_logs]) end - defp validate_field({_key, _value}, acc), do: acc + defp verify_update_field(key, _value, _acc, _account) + when key in [:name, :description, :active, :audit_details], + do: {:error, :invalid_field} + + defp verify_update_field(_key, _value, acc, _account), do: acc - defp maybe_create_account(%{params: params, errors: []}), do: {:ok, struct(__MODULE__, params)} - defp maybe_create_account(%{errors: errors}), do: List.first(errors) - defp maybe_create_account(_params), do: {:error, :invalid_params} + defp maybe_update({:error, reason}, _account), do: {:error, reason} + defp maybe_update(params, account), do: {:ok, Map.merge(account, params)} end diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs index 040f88c..c7a00d6 100644 --- a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs @@ -30,13 +30,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do active: true } - IO.inspect(random_string) - - result = Worker.create(params) - - IO.inspect(result) - - result + Worker.create(params) end }) @@ -74,4 +68,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do Worker.import_file("../../data/sample_chart_of_accounts.csv") end }) + + # Benchee.run(%{ + # "COA Server update/2" => fn -> + # ChartOfAccountsServer.import_accounts("../../data/sample_chart_of_accounts.csv") + # end, + # }) end diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs index a59ddd8..98e170f 100644 --- a/test/benchmark/bookkeeping/core/account_benchmark.exs +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -1,5 +1,5 @@ defmodule Bookkeeping.Core.AccountBenchmark do - alias Bookkeeping.Core.Account + alias Bookkeeping.Core.{Account, AuditLog} Benchee.run(%{ "create/1" => fn -> @@ -16,45 +16,84 @@ defmodule Bookkeeping.Core.AccountBenchmark do Account.create("1001", "Cash 1", "asset", "Cash and Cash Equivalents 1", %{}) end, "create/1 struct only" => fn -> + classification = Account.accounts_classification()["asset"] + audit_log = AuditLog.create("account", "create", %{}) + struct(%Account{}, %{ code: "1000", name: "Cash 0", - type: "asset", + type: classification, description: "Cash and Cash Equivalents 0", - audit_details: %{}, + audit_details: [audit_log], active: true }) end }) + # Benchee.run(%{ + # "validate_account/1" => fn -> + # Account.validate_account(%Account{ + # code: "1003", + # name: "Cash 3", + # classification: "asset", + # description: "Cash and Cash Equivalents 3", + # audit_logs: [], + # active: true + # }) + # end, + # "validate/1" => fn -> + # Account.validate(%Account{ + # code: "1004", + # name: "Cash 4", + # classification: "asset", + # description: "Cash and Cash Equivalents 4", + # audit_logs: [], + # active: true + # }) + # end, + # "validate2/1" => fn -> + # Account.validate2(%Account{ + # code: "1005", + # name: "Cash 5", + # classification: "asset", + # description: "Cash and Cash Equivalents 5", + # audit_logs: [], + # active: true + # }) + # end + # }) + + {:ok, cash_account} = + Account.create(%{ + code: "1000", + name: "Cash 0", + classification: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + }) + Benchee.run(%{ - "validate_account/1" => fn -> - Account.validate_account(%Account{ - code: "1003", - name: "Cash 3", - classification: "asset", - description: "Cash and Cash Equivalents 3", - audit_logs: [], - active: true - }) - end, - "validate/1" => fn -> - Account.validate(%Account{ - code: "1004", - name: "Cash 4", - classification: "asset", - description: "Cash and Cash Equivalents 4", - audit_logs: [], - active: true + "current update/2" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + Account.update(cash_account, %{ + code: random_string, + name: random_string, + description: "Cash and Cash Equivalents 1", + audit_details: %{email: "test@test.com"}, + active: false }) end, - "validate2/1" => fn -> - Account.validate2(%Account{ - code: "1005", - name: "Cash 5", + "new update/2" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + Account.update(cash_account, %{ + code: random_string, + name: random_string, classification: "asset", - description: "Cash and Cash Equivalents 5", - audit_logs: [], + description: "Cash and Cash Equivalents 2", + audit_details: %{}, active: true }) end From e728cd53a33d0fba8baf3865ee6813904d15161b Mon Sep 17 00:00:00 2001 From: jeryldev Date: Tue, 5 Dec 2023 23:53:36 +0800 Subject: [PATCH 12/32] add benchmarks for worker update --- .../boundary/chart_of_accounts_2/worker.ex | 53 +++++++++++++------ lib/bookkeeping/core/account.ex | 12 ++++- .../boundary/chart_of_accounts_benchmark.exs | 46 ++++++++++++++-- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex index 78f4936..8601d13 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex @@ -4,41 +4,44 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do alias Bookkeeping.Core.Account alias NimbleCSV.RFC4180, as: CSV - @spec start_link(any()) :: :ignore | {:error, any()} | {:ok, pid()} + @spec start_link(any()) :: {:ok, pid()} | {:error, any()} | {:error, :already_started} def start_link(_) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end @spec create(Account.create_params()) :: - {:error, :invalid_params} - | {:error, :invalid_field} - | {:error, :already_exists} - | {:ok, Account.t()} + {:ok, Account.t()} | {:error, :already_exists | :invalid_field | :invalid_params} def create(params) do GenServer.call(__MODULE__, {:create, params}) end @spec import_file(String.t()) :: - {:error, :invalid_file} - | {:ok, - %{ - oks: list(Account.t()), - errors: - list(%{ - reason: :invalid_params | :invalid_field | :already_exists, - params: Account.create_params() - }) - }} + {:ok, + %{ + oks: list(Account.t()), + errors: + list(%{ + reason: :invalid_params | :invalid_field | :already_exists, + params: Account.create_params() + }) + }} + | {:error, :invalid_file} def import_file(file_path) do file_path |> check_csv() |> read_csv() |> bulk_generate_params() |> bulk_create() end - @spec search_code(Account.account_code()) :: {:error, :not_found} | {:ok, Account.t()} + @spec update(Account.t(), Account.update_params()) :: + {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} + def update(account, params) do + GenServer.call(__MODULE__, {:update, account, params}) + end + + @spec search_code(Account.account_code()) :: {:ok, Account.t()} | {:error, :not_found} def search_code(code) do GenServer.call(__MODULE__, {:search_code, code}) end - @spec search_name(String.t()) :: {:error, :not_found} | {:ok, Account.t()} + @spec search_name(String.t()) :: {:ok, Account.t()} | {:error, :not_found} def search_name(name) do GenServer.call(__MODULE__, {:search_name, name}) end @@ -57,6 +60,11 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do {:reply, result, table} end + def handle_call({:update, account, params}, _from, table) do + result = update(table, account, params) + {:reply, result, table} + end + def handle_call({:search_code, code}, _from, table) do result = search_code(table, code) {:reply, result, table} @@ -75,6 +83,17 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do end end + defp update(table, account, params) do + case Account.update2(account, params) do + {:ok, account} -> + :ets.insert(table, {account.code, account.name, account}) + {:ok, account} + + {:error, reason} -> + {:error, reason} + end + end + defp check_similar_account(table, params) do code = Map.get(params, :code, "") name = Map.get(params, :name, "") diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 96d59d0..6cce733 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -35,6 +35,16 @@ defmodule Bookkeeping.Core.Account do active: boolean() } + @typedoc """ + update_params type is a map which represents the parameter used to update an account. + """ + @type update_params :: %{ + name: String.t(), + description: String.t(), + active: boolean(), + audit_details: map() + } + @account_classifications ~w(asset liability equity revenue expense gain loss contra_asset contra_liability contra_equity contra_revenue contra_expense contra_gain contra_loss) defstruct code: "", @@ -182,7 +192,7 @@ defmodule Bookkeeping.Core.Account do end end - @spec update2(any(), any()) :: + @spec update2(Account.t(), update_params()) :: {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} def update2(account, params) do params |> validate_update_params(account) |> maybe_update(account) diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs index c7a00d6..b2e798c 100644 --- a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs @@ -69,9 +69,45 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do end }) - # Benchee.run(%{ - # "COA Server update/2" => fn -> - # ChartOfAccountsServer.import_accounts("../../data/sample_chart_of_accounts.csv") - # end, - # }) + Benchee.run(%{ + "COA Server update/2" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + {:ok, account} = + ChartOfAccountsServer.create_account( + random_string, + random_string, + "asset", + "Cash and Cash Equivalents 0", + %{} + ) + + random_string = for _ <- 1..10, into: "", do: <> + + ChartOfAccountsServer.update_account( + account, + %{name: random_string, description: random_string, audit_details: %{}, active: false} + ) + end, + "COA Worker update/2" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + {:ok, account} = + Worker.create(%{ + code: random_string, + name: random_string, + classification: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + }) + + random_string = for _ <- 1..10, into: "", do: <> + + Worker.update( + account, + %{name: random_string, description: random_string, audit_details: %{}, active: false} + ) + end + }) end From e1fe164a5bcfe04a2389df7b025fcd1ec9ab07f3 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Wed, 6 Dec 2023 10:30:59 +0800 Subject: [PATCH 13/32] updated the benchmarks, move the classify function within the classification module, updated the worker update function, add tests --- .../boundary/chart_of_accounts_2/worker.ex | 10 +- lib/bookkeeping/core/account.ex | 324 ++++++++---------- .../bookkeeping/core/account_benchmark.exs | 12 +- test/bookkeeping/core/account_test.exs | 285 ++++++++++++--- 4 files changed, 387 insertions(+), 244 deletions(-) diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex index 8601d13..3cc51d7 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex @@ -84,13 +84,9 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do end defp update(table, account, params) do - case Account.update2(account, params) do - {:ok, account} -> - :ets.insert(table, {account.code, account.name, account}) - {:ok, account} - - {:error, reason} -> - {:error, reason} + with {:ok, account} <- Account.update(account, params) do + :ets.insert(table, {account.code, account.name, account}) + {:ok, account} end end diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 6cce733..b998258 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -74,53 +74,131 @@ defmodule Bookkeeping.Core.Account do normal_balance: nil, category: nil, contra: false - end - @doc """ - Creates a new account struct. + def classify("asset") do + %Classification{ + name: "Asset", + normal_balance: :debit, + category: :position, + contra: false + } + end - Arguments: - - code: The unique code of the account. - - name: The unique name of the account. - - classification: The classification of the account. The account classification must be one of the following: `"asset"`, `"liability"`, `"equity"`, `"revenue"`, `"expense"`, `"gain"`, `"loss"`, `"contra_asset"`, `"contra_liability"`, `"contra_equity"`, `"contra_revenue"`, `"contra_expense"`, `"contra_gain"`, `"contra_loss"`. - - description: The description of the account. - - audit_details: The details of the audit log. + def classify("liability") do + %Classification{ + name: "Liability", + normal_balance: :credit, + category: :position, + contra: false + } + end - Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`. + def classify("equity") do + %Classification{ + name: "Equity", + normal_balance: :credit, + category: :position, + contra: false + } + end - ## Examples + def classify("revenue") do + %Classification{ + name: "Revenue", + normal_balance: :credit, + category: :performance, + contra: false + } + end - iex> Account.create("10_000", "cash", "asset", "", %{}) - {:ok, %Account{...}} + def classify("expense") do + %Classification{ + name: "Expense", + normal_balance: :debit, + category: :performance, + contra: false + } + end - iex> Account.create("invalid", "invalid", "invalid", nil, false, %{}) - {:error, :invalid_account} - """ - @spec create(String.t(), String.t(), String.t(), String.t(), map()) :: - {:ok, Account.t()} | {:error, :invalid_account} - def create(code, name, classification, description, audit_details) do - classification_mapping = accounts_classification() - classification_keys = Map.keys(classification_mapping) - - valid_inputs? = - is_binary(code) and is_binary(name) and is_binary(classification) and - is_binary(description) and code != "" and name != "" and - classification in classification_keys and is_map(audit_details) - - classification = Map.get(classification_mapping, classification) - - with true <- valid_inputs?, - {:ok, audit_log} <- AuditLog.create("account", "create", audit_details) do - {:ok, - %__MODULE__{ - code: code, - name: name, - description: description, - classification: classification, - audit_logs: [audit_log] - }} - else - _ -> {:error, :invalid_account} + def classify("gain") do + %Classification{ + name: "Gain", + normal_balance: :credit, + category: :performance, + contra: false + } + end + + def classify("loss") do + %Classification{ + name: "Loss", + normal_balance: :debit, + category: :performance, + contra: false + } + end + + def classify("contra_asset") do + %Classification{ + name: "Contra Asset", + normal_balance: :credit, + category: :position, + contra: true + } + end + + def classify("contra_liability") do + %Classification{ + name: "Contra Liability", + normal_balance: :debit, + category: :position, + contra: true + } + end + + def classify("contra_equity") do + %Classification{ + name: "Contra Equity", + normal_balance: :debit, + category: :position, + contra: true + } + end + + def classify("contra_revenue") do + %Classification{ + name: "Contra Revenue", + normal_balance: :debit, + category: :performance, + contra: true + } + end + + def classify("contra_expense") do + %Classification{ + name: "Contra Expense", + normal_balance: :credit, + category: :performance, + contra: true + } + end + + def classify("contra_gain") do + %Classification{ + name: "Contra Gain", + normal_balance: :debit, + category: :performance, + contra: true + } + end + + def classify("contra_loss") do + %Classification{ + name: "Contra Loss", + normal_balance: :credit, + category: :performance, + contra: true + } end end @@ -159,42 +237,23 @@ defmodule Bookkeeping.Core.Account do ## Examples - iex> {:ok, account} = Account.create("10_000", "cash", "asset") + iex> {:ok, account} = Account.create(params) - iex> Account.update(account, %{name: "cash and cash equivalents"}) - {:ok, %Account{name: "cash and cash equivalents", ...}} - """ - @spec update(%__MODULE__{}, map()) :: {:ok, Account.t()} | {:error, :invalid_account} - def update(account, attrs) when is_map(attrs) do - name = Map.get(attrs, :name, account.name) - description = Map.get(attrs, :description, account.description) - active = Map.get(attrs, :active, account.active) - audit_details = Map.get(attrs, :audit_details, %{}) - - valid_fields? = - is_binary(name) and name != "" and is_binary(description) and - is_boolean(active) and is_map(audit_details) - - with true <- valid_fields?, - {:ok, audit_log} <- AuditLog.create("account", "update", audit_details) do - existing_audit_logs = Map.get(account, :audit_logs, []) - - update_params = %{ - name: name, - description: description, - active: active, - audit_logs: [audit_log | existing_audit_logs] - } + iex> Account.update(account, %{name: "cash and cash equivalents", description: "cash and cash equivalents", audit_details: %{}, active: false}) + {:ok, %Account{...}} - {:ok, Map.merge(account, update_params)} - else - _ -> {:error, :invalid_account} - end - end + iex> Account.update(%{}, %{name: "cash and cash equivalents"}) + {:error, :invalid_account} - @spec update2(Account.t(), update_params()) :: + iex> Account.update(account, %{name: nil}) + {:error, :invalid_field} + + iex> Account.update(account, nil) + {:error, :invalid_params} + """ + @spec update(Account.t(), update_params()) :: {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} - def update2(account, params) do + def update(account, params) do params |> validate_update_params(account) |> maybe_update(account) end @@ -224,95 +283,6 @@ defmodule Bookkeeping.Core.Account do else: {:error, :invalid_account} end - def accounts_classification do - %{ - "asset" => %Classification{ - name: "Asset", - normal_balance: :debit, - category: :position, - contra: false - }, - "liability" => %Classification{ - name: "Liability", - normal_balance: :credit, - category: :position, - contra: false - }, - "equity" => %Classification{ - name: "Equity", - normal_balance: :credit, - category: :position, - contra: false - }, - "revenue" => %Classification{ - name: "Revenue", - normal_balance: :credit, - category: :performance, - contra: false - }, - "expense" => %Classification{ - name: "Expense", - normal_balance: :debit, - category: :performance, - contra: false - }, - "gain" => %Classification{ - name: "Gain", - normal_balance: :credit, - category: :performance, - contra: false - }, - "loss" => %Classification{ - name: "Loss", - normal_balance: :debit, - category: :performance, - contra: false - }, - "contra_asset" => %Classification{ - name: "Contra Asset", - normal_balance: :credit, - category: :position, - contra: true - }, - "contra_liability" => %Classification{ - name: "Contra Liability", - normal_balance: :debit, - category: :position, - contra: true - }, - "contra_equity" => %Classification{ - name: "Contra Equity", - normal_balance: :debit, - category: :position, - contra: true - }, - "contra_revenue" => %Classification{ - name: "Contra Revenue", - normal_balance: :debit, - category: :performance, - contra: true - }, - "contra_expense" => %Classification{ - name: "Contra Expense", - normal_balance: :credit, - category: :performance, - contra: true - }, - "contra_gain" => %Classification{ - name: "Contra Gain", - normal_balance: :debit, - category: :performance, - contra: true - }, - "contra_loss" => %Classification{ - name: "Contra Loss", - normal_balance: :credit, - category: :performance, - contra: true - } - } - end - defp validate_create_params( %{ code: code, @@ -342,7 +312,7 @@ defmodule Bookkeeping.Core.Account do active: active }) do {:ok, audit_log} = AuditLog.create("account", "create", audit_details) - classification = Map.get(accounts_classification(), classification) + classification = Classification.classify(classification) {:ok, %__MODULE__{ @@ -360,13 +330,13 @@ defmodule Bookkeeping.Core.Account do defp validate_update_params(params, _account) when not is_map(params), do: {:error, :invalid_params} - defp validate_update_params(_params, account) when not is_struct(account, __MODULE__), - do: {:error, :invalid_account} - defp validate_update_params(%{audit_details: _audit_details} = params, account) do - Enum.reduce(params, %{}, fn {key, value}, acc -> - verify_update_field(key, value, acc, account) - end) + with {:ok, _} <- validate(account) do + Enum.reduce(params, %{}, fn + {_key, _value}, {:error, :invalid_field} -> {:error, :invalid_field} + {key, value}, acc -> verify_update_field(key, value, acc, account) + end) + end end defp validate_update_params(params, account) do @@ -380,19 +350,19 @@ defmodule Bookkeeping.Core.Account do is_binary(value) and value != "", do: Map.put(acc, key, value) - defp verify_update_field(:active, value, acc, _account) when is_boolean(value), - do: Map.put(acc, :active, value) + defp verify_update_field(key, value, acc, _account) + when key == :active and is_boolean(value), + do: Map.put(acc, key, value) - defp verify_update_field(:audit_details, value, acc, account) when is_map(value) do + defp verify_update_field(key, value, acc, account) + when key == :audit_details and is_map(value) do {:ok, audit_log} = AuditLog.create("account", "update", value) Map.put(acc, :audit_logs, [audit_log | account.audit_logs]) end - defp verify_update_field(key, _value, _acc, _account) - when key in [:name, :description, :active, :audit_details], - do: {:error, :invalid_field} - - defp verify_update_field(_key, _value, acc, _account), do: acc + defp verify_update_field(_key, _value, _acc, _account) do + {:error, :invalid_field} + end defp maybe_update({:error, reason}, _account), do: {:error, reason} defp maybe_update(params, account), do: {:ok, Map.merge(account, params)} diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs index 98e170f..616c2e9 100644 --- a/test/benchmark/bookkeeping/core/account_benchmark.exs +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -12,12 +12,12 @@ defmodule Bookkeeping.Core.AccountBenchmark do active: true }) end, - "create/5" => fn -> - Account.create("1001", "Cash 1", "asset", "Cash and Cash Equivalents 1", %{}) - end, + # "create/5" => fn -> + # Account.create("1001", "Cash 1", "asset", "Cash and Cash Equivalents 1", %{}) + # end, "create/1 struct only" => fn -> - classification = Account.accounts_classification()["asset"] audit_log = AuditLog.create("account", "create", %{}) + classification = Account.Classification.classify("asset") struct(%Account{}, %{ code: "1000", @@ -93,8 +93,8 @@ defmodule Bookkeeping.Core.AccountBenchmark do name: random_string, classification: "asset", description: "Cash and Cash Equivalents 2", - audit_details: %{}, - active: true + audit_details: %{email: "test@test.com"}, + active: false }) end }) diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index 06dac3c..935edca 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -7,85 +7,262 @@ defmodule Bookkeeping.Core.AccountTest do {:ok, details: details} end - test "allow integer code, binary name and account classification account field", %{ - details: details - } do - assert {:ok, new_account} = - Account.create("10_000", "cash", "asset", "description", details) - - assert new_account.code == "10_000" - assert new_account.name == "cash" - assert new_account.classification.name == "Asset" - assert new_account.classification.normal_balance == :debit - - assert {:ok, _valid_account} = Account.validate(new_account) - end + test "create/1 with valid params", %{details: details} do + assert {:ok, account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true + }) - test "create account with description and active fields", %{details: details} do - assert {:ok, new_account} = - Account.create("10_010", "cash", "asset", "cash and cash equivalents", details) + assert account.code == "10_000" + assert account.name == "cash" + assert account.classification.name == "Asset" + assert account.classification.normal_balance == :debit + assert account.description == "description" + assert account.active - assert new_account.code == "10_010" - assert new_account.name == "cash" - assert new_account.classification.name == "Asset" - assert new_account.classification.normal_balance == :debit - assert new_account.description == "cash and cash equivalents" - assert new_account.active - end + assert {:ok, _liability} = + Account.create(%{ + code: "20_000", + name: "liability", + classification: "liability", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _equity} = + Account.create(%{ + code: "30_000", + name: "equity", + classification: "equity", + description: "description", + audit_details: details, + active: true + }) - test "disallow non-binary code field", %{details: details} do - new_account = Account.create(10_000, "cash", "asset", "description", details) + assert {:ok, _revenue} = + Account.create(%{ + code: "40_000", + name: "revenue", + classification: "revenue", + description: "description", + audit_details: details, + active: true + }) - assert ^new_account = {:error, :invalid_account} - end + assert {:ok, _expense} = + Account.create(%{ + code: "50_000", + name: "expense", + classification: "expense", + description: "description", + audit_details: details, + active: true + }) - test "disallow non-binary name field", %{details: details} do - new_account = Account.create(10_000, 10_000, "asset", "description", details) + assert {:ok, _gain} = + Account.create(%{ + code: "50_000", + name: "gain", + classification: "gain", + description: "description", + audit_details: details, + active: true + }) - assert ^new_account = {:error, :invalid_account} - end + assert {:ok, _loss} = + Account.create(%{ + code: "50_000", + name: "loss", + classification: "loss", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_asset} = + Account.create(%{ + code: "60_000", + name: "contra_asset", + classification: "contra_asset", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_liability} = + Account.create(%{ + code: "70_000", + name: "contra_liability", + classification: "contra_liability", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_equity} = + Account.create(%{ + code: "80_000", + name: "contra_equity", + classification: "contra_equity", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_revenue} = + Account.create(%{ + code: "90_000", + name: "contra_revenue", + classification: "contra_revenue", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_expense} = + Account.create(%{ + code: "100_000", + name: "contra_expense", + classification: "contra_expense", + description: "description", + audit_details: details, + active: true + }) - test "disallow non-%AccountClassification{} account field", %{details: details} do - new_account = Account.create(10_000, "cash", "classification", "description", details) + assert {:ok, _contra_gain} = + Account.create(%{ + code: "100_000", + name: "contra_gain", + classification: "contra_gain", + description: "description", + audit_details: details, + active: true + }) - assert ^new_account = {:error, :invalid_account} + assert {:ok, _contra_loss} = + Account.create(%{ + code: "100_000", + name: "contra_loss", + classification: "contra_loss", + description: "description", + audit_details: details, + active: true + }) end - test "disallow empty name", %{details: details} do - new_account = Account.create(10_000, "", "asset", "description", details) + test "create/1 with invalid params", %{details: details} do + assert {:error, :invalid_params} = Account.create(%{}) + assert {:error, :invalid_params} = Account.create(nil) + + assert {:error, :invalid_params} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description" + }) + + assert {:error, :invalid_field} = + Account.create(%{ + code: 10_000, + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true + }) - assert ^new_account = {:error, :invalid_account} + assert {:error, :invalid_field} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "classification", + description: "description", + audit_details: details, + active: true + }) end - test "update account", %{details: details} do + test "update/2 with valid params", %{details: details} do assert {:ok, account} = - Account.create("10_000", "cash", "asset", "description", details) + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, account_2} = + Account.update(account, %{ + name: "cash and cash equivalents", + description: "description 2", + audit_details: %{email: "test@test.com"}, + active: false + }) - assert {:ok, account_2} = Account.update(account, %{name: "cash and cash equivalents"}) assert account.code == account_2.code refute account.name == account_2.name assert account.classification == account_2.classification - assert {:error, :invalid_account} = Account.update(account, %{name: ""}) + refute account.description == account_2.description + refute account.active == account_2.active + end - assert {:ok, account_3} = - Account.update(account, %{ - code: "10_001", - name: "trade payables", - classification: "liability" + test "update/2 with invalid params", %{details: details} do + assert {:error, :invalid_account} = Account.update(%Account{}, %{}) + assert {:error, :invalid_params} = Account.update(%{}, nil) + assert {:error, :invalid_account} = Account.update(%{}, %{}) + + assert {:ok, account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true }) - assert account.code == account_3.code - refute account.name == account_3.name - assert account.classification == account_3.classification + assert {:error, :invalid_field} = Account.update(account, %{name: nil}) - assert {:ok, _account_4} = - Account.update(account, %{ - code: "10_001", - name: "cash and cash equivalents" + assert {:error, :invalid_field} = + Account.update(account, %{name: nil, active: false, test: "test"}) + + assert {:error, :invalid_params} = Account.update(account, nil) + end + + test "validate/1 with valid params", %{details: details} do + assert {:ok, account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true }) + + assert {:ok, account} = Account.validate(account) end - test "validate account" do + test "validate/1 with invalid params", %{details: details} do + assert {:error, :invalid_account} = Account.validate(%{}) + assert {:error, :invalid_account} = Account.validate(nil) assert {:error, :invalid_account} = Account.validate(%Account{}) + + assert {:error, :invalid_account} = + Account.validate(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description" + }) end end From 3a5a7f6ac20ab7ee9cdeee12912fde91af34aaea Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 7 Dec 2023 07:20:48 +0800 Subject: [PATCH 14/32] arrange account tests --- test/bookkeeping/core/account_test.exs | 557 ++++++++++++++----------- 1 file changed, 302 insertions(+), 255 deletions(-) diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index 935edca..33f455e 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -7,262 +7,309 @@ defmodule Bookkeeping.Core.AccountTest do {:ok, details: details} end - test "create/1 with valid params", %{details: details} do - assert {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - - assert account.code == "10_000" - assert account.name == "cash" - assert account.classification.name == "Asset" - assert account.classification.normal_balance == :debit - assert account.description == "description" - assert account.active - - assert {:ok, _liability} = - Account.create(%{ - code: "20_000", - name: "liability", - classification: "liability", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _equity} = - Account.create(%{ - code: "30_000", - name: "equity", - classification: "equity", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _revenue} = - Account.create(%{ - code: "40_000", - name: "revenue", - classification: "revenue", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _expense} = - Account.create(%{ - code: "50_000", - name: "expense", - classification: "expense", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _gain} = - Account.create(%{ - code: "50_000", - name: "gain", - classification: "gain", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _loss} = - Account.create(%{ - code: "50_000", - name: "loss", - classification: "loss", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_asset} = - Account.create(%{ - code: "60_000", - name: "contra_asset", - classification: "contra_asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_liability} = - Account.create(%{ - code: "70_000", - name: "contra_liability", - classification: "contra_liability", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_equity} = - Account.create(%{ - code: "80_000", - name: "contra_equity", - classification: "contra_equity", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_revenue} = - Account.create(%{ - code: "90_000", - name: "contra_revenue", - classification: "contra_revenue", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_expense} = - Account.create(%{ - code: "100_000", - name: "contra_expense", - classification: "contra_expense", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_gain} = - Account.create(%{ - code: "100_000", - name: "contra_gain", - classification: "contra_gain", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_loss} = - Account.create(%{ - code: "100_000", - name: "contra_loss", - classification: "contra_loss", - description: "description", - audit_details: details, - active: true - }) + describe "create/1" do + test "with valid params", %{details: details} do + assert {:ok, account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true + }) + + assert account.code == "10_000" + assert account.name == "cash" + assert account.classification.name == "Asset" + assert account.classification.normal_balance == :debit + assert account.description == "description" + assert is_boolean(account.active) + assert is_list(account.audit_logs) + assert is_struct(account.classification, Bookkeeping.Core.Account.Classification) + + assert {:ok, _liability} = + Account.create(%{ + code: "20_000", + name: "liability", + classification: "liability", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _equity} = + Account.create(%{ + code: "30_000", + name: "equity", + classification: "equity", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _revenue} = + Account.create(%{ + code: "40_000", + name: "revenue", + classification: "revenue", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _expense} = + Account.create(%{ + code: "50_000", + name: "expense", + classification: "expense", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _gain} = + Account.create(%{ + code: "50_000", + name: "gain", + classification: "gain", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _loss} = + Account.create(%{ + code: "50_000", + name: "loss", + classification: "loss", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_asset} = + Account.create(%{ + code: "60_000", + name: "contra_asset", + classification: "contra_asset", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_liability} = + Account.create(%{ + code: "70_000", + name: "contra_liability", + classification: "contra_liability", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_equity} = + Account.create(%{ + code: "80_000", + name: "contra_equity", + classification: "contra_equity", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_revenue} = + Account.create(%{ + code: "90_000", + name: "contra_revenue", + classification: "contra_revenue", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_expense} = + Account.create(%{ + code: "100_000", + name: "contra_expense", + classification: "contra_expense", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_gain} = + Account.create(%{ + code: "100_000", + name: "contra_gain", + classification: "contra_gain", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, _contra_loss} = + Account.create(%{ + code: "100_000", + name: "contra_loss", + classification: "contra_loss", + description: "description", + audit_details: details, + active: true + }) + end + + test "create/1 with invalid params", %{details: details} do + assert {:error, :invalid_params} = Account.create(%{}) + assert {:error, :invalid_params} = Account.create(nil) + assert {:error, :invalid_params} = Account.create("apple") + + assert {:error, :invalid_params} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description" + }) + + assert {:error, :invalid_params} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details + }) + end + + test "with invalid field", %{details: details} do + assert {:error, :invalid_field} = + Account.create(%{ + code: 10_000, + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true + }) + + assert {:error, :invalid_field} = + Account.create(%{ + code: "10_000", + name: nil, + classification: "asset", + description: "description", + audit_details: details, + active: true + }) + + assert {:error, :invalid_field} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: nil, + description: "description", + audit_details: details, + active: true + }) + + assert {:error, :invalid_field} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: nil, + audit_details: details, + active: true + }) + + assert {:error, :invalid_field} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: nil, + active: true + }) + + assert {:error, :invalid_field} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: nil + }) + end end - test "create/1 with invalid params", %{details: details} do - assert {:error, :invalid_params} = Account.create(%{}) - assert {:error, :invalid_params} = Account.create(nil) - - assert {:error, :invalid_params} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description" - }) - - assert {:error, :invalid_field} = - Account.create(%{ - code: 10_000, - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:error, :invalid_field} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "classification", - description: "description", - audit_details: details, - active: true - }) - end - - test "update/2 with valid params", %{details: details} do - assert {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, account_2} = - Account.update(account, %{ - name: "cash and cash equivalents", - description: "description 2", - audit_details: %{email: "test@test.com"}, - active: false - }) - - assert account.code == account_2.code - refute account.name == account_2.name - assert account.classification == account_2.classification - refute account.description == account_2.description - refute account.active == account_2.active - end - - test "update/2 with invalid params", %{details: details} do - assert {:error, :invalid_account} = Account.update(%Account{}, %{}) - assert {:error, :invalid_params} = Account.update(%{}, nil) - assert {:error, :invalid_account} = Account.update(%{}, %{}) - - assert {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:error, :invalid_field} = Account.update(account, %{name: nil}) - - assert {:error, :invalid_field} = - Account.update(account, %{name: nil, active: false, test: "test"}) - - assert {:error, :invalid_params} = Account.update(account, nil) - end - - test "validate/1 with valid params", %{details: details} do - assert {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, account} = Account.validate(account) - end - - test "validate/1 with invalid params", %{details: details} do - assert {:error, :invalid_account} = Account.validate(%{}) - assert {:error, :invalid_account} = Account.validate(nil) - assert {:error, :invalid_account} = Account.validate(%Account{}) - - assert {:error, :invalid_account} = - Account.validate(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description" - }) + describe "update/2" do + test "with valid params", %{details: details} do + assert {:ok, account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: details, + active: true + }) + + assert {:ok, account_2} = + Account.update(account, %{ + name: "cash and cash equivalents", + description: "description 2", + audit_details: %{email: "test@test.com"}, + active: false + }) + + assert account.code == account_2.code + refute account.name == account_2.name + assert account.classification == account_2.classification + refute account.description == account_2.description + refute account.active == account_2.active + end + + test "with invalid account" do + assert {:error, :invalid_account} = Account.update(%Account{}, %{}) + assert {:error, :invalid_account} = Account.update(nil, %{}) + end + + test "with invalid field" do + {:ok, account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: %{}, + active: true + }) + + assert {:error, :invalid_field} = Account.update(account, %{name: nil}) + assert {:error, :invalid_field} = Account.update(account, %{name: "cash", active: nil}) + assert {:error, :invalid_field} = Account.update(account, %{name: "cash", test: "test"}) + + assert {:error, :invalid_field} = + Account.update(account, %{name: "cash", classification: nil}) + end + + test "with invalid params" do + {:ok, account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: %{}, + active: true + }) + + assert {:error, :invalid_params} = Account.update(account, nil) + assert {:error, :invalid_params} = Account.update(account, "apple") + assert {:error, :invalid_params} = Account.update(account, %{}) + end end end From d198f0c024d61ed7403b2c611af7f4d4cd671dca Mon Sep 17 00:00:00 2001 From: jeryldev Date: Fri, 8 Dec 2023 11:09:26 +0800 Subject: [PATCH 15/32] updated the csv files --- ...r_chart_of_accounts.csv => empty_chart_of_accounts_2.csv} | 4 +++- test/bookkeeping/data/empty_text_file.txt | 0 test/bookkeeping/data/partially_valid_chart_of_accounts.csv | 5 +++-- 3 files changed, 6 insertions(+), 3 deletions(-) rename test/bookkeeping/data/{decode_error_chart_of_accounts.csv => empty_chart_of_accounts_2.csv} (52%) create mode 100644 test/bookkeeping/data/empty_text_file.txt diff --git a/test/bookkeeping/data/decode_error_chart_of_accounts.csv b/test/bookkeeping/data/empty_chart_of_accounts_2.csv similarity index 52% rename from test/bookkeeping/data/decode_error_chart_of_accounts.csv rename to test/bookkeeping/data/empty_chart_of_accounts_2.csv index d055f04..0015bfa 100644 --- a/test/bookkeeping/data/decode_error_chart_of_accounts.csv +++ b/test/bookkeeping/data/empty_chart_of_accounts_2.csv @@ -1,2 +1,4 @@ Account Code,Account Name,Account Type,Account Description,Audit Details -10000010,Accounts Receivable Bulk Test,asset,Accounts Receivable, + + + diff --git a/test/bookkeeping/data/empty_text_file.txt b/test/bookkeeping/data/empty_text_file.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/bookkeeping/data/partially_valid_chart_of_accounts.csv b/test/bookkeeping/data/partially_valid_chart_of_accounts.csv index 178eccc..97e61fb 100644 --- a/test/bookkeeping/data/partially_valid_chart_of_accounts.csv +++ b/test/bookkeeping/data/partially_valid_chart_of_accounts.csv @@ -1,10 +1,11 @@ Account Code,Account Name,Account Type,Account Description,Audit Details 1000001012,Accounts Receivable Bulk Test 2,asset,Accounts Receivable,{} 1000001012,Accounts Receivable Bulk Test 2,asset,Accounts Receivable,{} -1000000012,Cash Bulk Test 2,asset,Cash,"{""approved_by"": ""example@example.com""}" +1000000012,Cash Bulk Test 2,asset,Cash,"test" 1012,Cash 2,asset,Cash,"{""approved_by"": ""example@example.com""}" 1032,Inventory 2,asset,Inventory,"{""approved_by"": ""example@example.com""}" 1042,"Property, Plant, and Equipment 2",asset,"Property, Plant, and Equipment","{""approved_by"": ""example@example.com""}" -2012,Accounts Payable 2,liability,Accounts Payable,"{""approved_by"": ""example@example.com""}" +2012,Accounts Payable 2,nil,Accounts Payable,"{""approved_by"": ""example@example.com""}" 2022,Short-term Debt 2,liability,Short-term Debt,"{""approved_by"": ""example@example.com""}" 2032,Long-term Debt 2,liability,Long-term Debt,"{""approved_by"": ""example@example.com""}" +nil From ce1bd382d68d760c4f98abb57378d09e7bf98a30 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Fri, 8 Dec 2023 11:09:58 +0800 Subject: [PATCH 16/32] fix the chart of accounts v2 and write tests --- .../boundary/chart_of_accounts_2/manager.ex | 6 +- .../boundary/chart_of_accounts_2/worker.ex | 83 ++- lib/bookkeeping/core/account.ex | 4 +- .../boundary/chart_of_accounts_test.exs | 519 +++++++----------- 4 files changed, 276 insertions(+), 336 deletions(-) diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex index 18f4ec9..9536f22 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex @@ -4,7 +4,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Manager do alias Bookkeeping.Boundary.ChartOfAccounts2.Worker def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + case GenServer.start_link(__MODULE__, :ok, name: __MODULE__) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + end end def init(_) do @@ -14,6 +17,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Manager do table = :ets.new(:give_away, [ + :ordered_set, :private, write_concurrency: true, read_concurrency: true diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex index 3cc51d7..bcacd9f 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex @@ -6,7 +6,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do @spec start_link(any()) :: {:ok, pid()} | {:error, any()} | {:error, :already_started} def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + case GenServer.start_link(__MODULE__, :ok, name: __MODULE__) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + end end @spec create(Account.create_params()) :: @@ -18,7 +21,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do @spec import_file(String.t()) :: {:ok, %{ - oks: list(Account.t()), + accounts: list(Account.t()), errors: list(%{ reason: :invalid_params | :invalid_field | :already_exists, @@ -36,7 +39,8 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do GenServer.call(__MODULE__, {:update, account, params}) end - @spec search_code(Account.account_code()) :: {:ok, Account.t()} | {:error, :not_found} + @spec search_code(Account.account_code()) :: + {:ok, Account.t()} | {:error, :not_found | :invalid_code} def search_code(code) do GenServer.call(__MODULE__, {:search_code, code}) end @@ -65,19 +69,17 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do {:reply, result, table} end - def handle_call({:search_code, code}, _from, table) do - result = search_code(table, code) - {:reply, result, table} + def handle_call({:search_code, prefix}, _from, table) do + {:reply, prefix_search_code(table, prefix), table} end - def handle_call({:search_name, name}, _from, table) do - result = search_name(table, name) - {:reply, result, table} + def handle_call({:search_name, prefix}, _from, table) do + {:reply, prefix_search_name(table, prefix), table} end defp create(table, params) do - with {:ok, params} <- check_similar_account(table, params), - {:ok, account} <- Account.create(params) do + with {:ok, account} <- Account.create(params), + {:error, :not_found} <- check_similar_account(table, account) do :ets.insert(table, {account.code, account.name, account}) {:ok, account} end @@ -90,15 +92,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do end end - defp check_similar_account(table, params) do - code = Map.get(params, :code, "") - name = Map.get(params, :name, "") - - with {:error, :not_found} <- search_code(table, code), - {:error, :not_found} <- search_name(table, name) do - {:ok, params} - else - {:ok, _account} -> {:error, :already_exists} + defp check_similar_account(table, account) do + with {:ok, _account} <- search_code(table, account.code), + {:ok, _account} <- search_name(table, account.name) do + {:error, :already_exists} end end @@ -110,10 +107,48 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do end defp search_name(table, name) do - result = :ets.match(table, {:_, name, :"$1"}) |> List.flatten() |> List.first() + result = :ets.select(table, [{{:_, name, :"$1"}, [], [:"$1"]}]) |> List.first() if is_nil(result), do: {:error, :not_found}, else: {:ok, result} end + defp prefix_search_code(table, prefix) when is_binary(prefix) and prefix != "" do + accounts = + :ets.foldl( + fn + {code, _, account}, acc -> + if String.starts_with?(code, prefix), do: [account | acc], else: acc + + _, acc -> + acc + end, + [], + table + ) + + {:ok, accounts} + end + + defp prefix_search_code(_table, _code), do: {:error, :invalid_code} + + defp prefix_search_name(table, prefix) when is_binary(prefix) and prefix != "" do + accounts = + :ets.foldl( + fn + {_, name, account}, acc -> + if String.starts_with?(name, prefix), do: [account | acc], else: acc + + _, acc -> + acc + end, + [], + table + ) + + {:ok, accounts} + end + + defp prefix_search_name(_table, _name), do: {:error, :invalid_name} + defp check_csv(path) when is_binary(path) do file_path = Path.expand(path, __DIR__) @@ -168,12 +203,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do defp bulk_generate_params(error), do: error - defp bulk_create([]), do: {:error, :invalid_file} - defp bulk_create(params_list) when is_list(params_list) do - Enum.reduce(params_list, %{oks: [], errors: []}, fn params, acc -> + Enum.reduce(params_list, %{accounts: [], errors: []}, fn params, acc -> case create(params) do - {:ok, account} -> %{acc | oks: [account | acc.oks]} + {:ok, account} -> %{acc | accounts: [account | acc.accounts]} {:error, reason} -> %{acc | errors: [%{reason: reason, params: params} | acc.errors]} end end) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index b998258..23b5386 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -233,7 +233,7 @@ defmodule Bookkeeping.Core.Account do - account: The account to be updated. - attrs: The attributes to be updated. The editable attributes are `name`, `description`, `active`, and `audit_details`. - Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`. + Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`, `{:error, :invalid_field}`, or `{:error, :invalid_params}`. ## Examples @@ -327,7 +327,7 @@ defmodule Bookkeeping.Core.Account do defp maybe_create({:error, reason}), do: {:error, reason} - defp validate_update_params(params, _account) when not is_map(params), + defp validate_update_params(params, _account) when not is_map(params) or params == %{}, do: {:error, :invalid_params} defp validate_update_params(%{audit_details: _audit_details} = params, account) do diff --git a/test/bookkeeping/boundary/chart_of_accounts_test.exs b/test/bookkeeping/boundary/chart_of_accounts_test.exs index 21cb31d..c28bfe0 100644 --- a/test/bookkeeping/boundary/chart_of_accounts_test.exs +++ b/test/bookkeeping/boundary/chart_of_accounts_test.exs @@ -1,326 +1,229 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do - use ExUnit.Case, async: true - alias Bookkeeping.Boundary.ChartOfAccounts.Backup, as: ChartOfAccountsBackup - alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer + use ExUnit.Case + alias Bookkeeping.Boundary.ChartOfAccounts2.Worker, as: ChartOfAccounts + alias Bookkeeping.Boundary.ChartOfAccounts2.Supervisor, as: ChartOfAccountsSupervisor setup do - description = "description" - details = %{email: "example@example.com"} - - {:ok, details: details, description: description} - end - - test "start link" do - {:ok, server} = ChartOfAccountsServer.start_link() - assert server in Process.list() - end - - test "create account", %{description: description, details: details} do - assert {:ok, account} = - ChartOfAccountsServer.create_account( - "1000", - "Cash1", - "asset", - description, - details - ) - - assert account.code == "1000" - assert account.name == "Cash1" - - assert {:error, :account_already_exists} = - ChartOfAccountsServer.create_account( - "1000", - "Cash1", - "asset", - description, - details - ) - - assert {:error, :invalid_account} = - ChartOfAccountsServer.create_account( - "1002", - "Inventory", - "invalid", - description, - true, - details - ) - - assert {:error, :invalid_account} = - ChartOfAccountsServer.create_account( - "1003", - "", - "asset", - description, - details - ) - end - - test "import default accounts" do - assert {:ok, []} = ChartOfAccountsServer.reset_accounts() - - # importing a valid file - assert {:ok, %{ok: _oks, error: _errors}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" - ) - - # importing an invalid or missing file - assert {:error, :invalid_file} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/invalid_file.csv" - ) - - # importing accounts with empty fields - assert {:error, %{message: :invalid_csv, errors: _errors}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/invalid_chart_of_accounts.csv" - ) - - # importing an empty file - assert {:error, :invalid_file} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/empty_chart_of_accounts.csv" - ) - - # importing accounts with invalid account classification - assert {:error, %{message: :invalid_csv, errors: _errors}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/decode_error_chart_of_accounts.csv" - ) - - # importing duplicate accounts in a single file - assert {:ok, %{ok: _oks, error: _errors}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/duplicate_chart_of_accounts.csv" - ) - - # importing the file twice - assert {:error, %{ok: _oks, error: _errors}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/duplicate_chart_of_accounts.csv" - ) - - # importing a partially valid file - assert {:ok, %{error: errors, ok: oks}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/partially_valid_chart_of_accounts.csv" - ) - - assert errors == [ - %{ - error: :account_already_exists, - account_code: "1000001012", - account_name: "Accounts Receivable Bulk Test 2" - } - ] - - assert Enum.count(oks) == 8 - end - - test "update account" do - assert {:ok, account} = - ChartOfAccountsServer.create_account( - "1000update", - "Cash original", - "asset", - "", - %{} - ) - - assert {:ok, updated_account} = - ChartOfAccountsServer.update_account(account, %{name: "Cash updated"}) - - assert updated_account.code == "1000update" - assert updated_account.name == "Cash updated" - assert updated_account.classification.name == "Asset" - assert {:error, :invalid_account} = ChartOfAccountsServer.update_account(account, %{name: ""}) - - assert {:error, :invalid_account} = - ChartOfAccountsServer.update_account(account, %{name: 1000}) - - assert {:ok, new_updated_account} = ChartOfAccountsServer.update_account(updated_account, %{}) - assert updated_account.code == new_updated_account.code - assert updated_account.name == new_updated_account.name - assert updated_account.classification == new_updated_account.classification + params = %{ + code: "1000", + name: "Cash", + classification: "asset", + description: "description", + audit_details: %{email: "example@example.com"}, + active: true + } + + invalid_params = %{ + code: "1000", + name: "Cash", + classification: "invalid", + description: "description", + audit_details: %{email: "example@example.com"}, + active: true + } + + {:ok, server_pid} = ChartOfAccountsSupervisor.start_link([]) + + {:ok, params: params, invalid_params: invalid_params, server_pid: server_pid} end - test "all accounts" do - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - assert is_list(accounts) - end - - test "find account by code" do - assert {:ok, account} = - ChartOfAccountsServer.create_account( - "1001", - "Accounts receivable", - "asset", - "", - %{} - ) - - assert {:ok, account} = ChartOfAccountsServer.find_account_by_code(account.code) - assert account.code == "1001" - assert account.name == "Accounts receivable" - assert {:error, :not_found} = ChartOfAccountsServer.find_account_by_code("2001") - assert {:error, :invalid_code} = ChartOfAccountsServer.find_account_by_code(nil) - end + describe "Worker start_link/1 " do + test "with valid params" do + {:ok, server} = ChartOfAccounts.start_link([]) + assert server in Process.list() + end - test "find account by name" do - assert {:ok, account} = - ChartOfAccountsServer.create_account( - "10010000", - "Accounts receivable4", - "asset", - "", - %{} - ) - - assert {:ok, account} = ChartOfAccountsServer.find_account_by_name(account.name) - assert account.code == "10010000" - assert account.name == "Accounts receivable4" - assert {:error, :not_found} = ChartOfAccountsServer.find_account_by_name("Accounts payable4") - assert {:error, :invalid_name} = ChartOfAccountsServer.find_account_by_name(nil) + test "with invalid params" do + {:ok, server} = ChartOfAccounts.start_link(test: nil) + end end - test "search accounts by code or name" do - assert {:ok, account_1} = - ChartOfAccountsServer.create_account("100100", "Cash2", "asset", "", %{}) - - assert {:ok, account_2} = - ChartOfAccountsServer.create_account("100200", "Receivable2", "asset", "", %{}) - - assert {:ok, account_3} = - ChartOfAccountsServer.create_account("100300", "Inventory2", "asset", "", %{}) - - assert {:ok, accounts} = ChartOfAccountsServer.search_accounts("100") - assert Enum.member?(accounts, account_1) - assert Enum.member?(accounts, account_2) - assert Enum.member?(accounts, account_3) - - assert {:ok, accounts} = ChartOfAccountsServer.search_accounts("receivable") - refute Enum.member?(accounts, account_1) - assert Enum.member?(accounts, account_2) - refute Enum.member?(accounts, account_3) - - assert {:error, :invalid_query} = ChartOfAccountsServer.search_accounts(nil) - assert {:error, :invalid_query} = ChartOfAccountsServer.search_accounts(%{}) + describe "create/1" do + test "with valid params", %{params: params} do + assert {:ok, account} = ChartOfAccounts.create(params) + assert account.code == "1000" + assert account.name == "Cash" + assert account.classification.name == "Asset" + assert account.description == "description" + assert is_struct(account.classification, Bookkeeping.Core.Account.Classification) + assert is_list(account.audit_logs) + end + + test "with invalid params" do + assert {:error, :invalid_params} = ChartOfAccounts.create("apple") + assert {:error, :invalid_params} = ChartOfAccounts.create(%{}) + assert {:error, :invalid_params} = ChartOfAccounts.create(%{code: "1000"}) + assert {:error, :invalid_params} = ChartOfAccounts.create(%{name: "Cash"}) + assert {:error, :invalid_params} = ChartOfAccounts.create(%{classification: "asset"}) + assert {:error, :invalid_params} = ChartOfAccounts.create(%{description: "description"}) + + assert {:error, :invalid_params} = + ChartOfAccounts.create(%{audit_details: %{email: "example@example.com"}}) + + assert {:error, :invalid_params} = ChartOfAccounts.create(%{active: true}) + end + + test "with invalid field", %{params: params, invalid_params: invalid_params} do + assert {:error, :invalid_field} = ChartOfAccounts.create(invalid_params) + end + + test "that already exists", %{params: params} do + assert {:ok, account} = ChartOfAccounts.create(params) + assert {:error, :already_exists} = ChartOfAccounts.create(params) + end end - test "get all sorted accounts by code or name" do - assert {:ok, account_1} = - ChartOfAccountsServer.create_account("1001000", "Cash4", "asset", "", %{}) - - assert {:ok, account_2} = - ChartOfAccountsServer.create_account("1002000", "Receivable4", "asset", "", %{}) - - assert {:ok, account_3} = - ChartOfAccountsServer.create_account("1003000", "Inventory4", "asset", "", %{}) - - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - assert Enum.member?(accounts, account_1) - assert Enum.member?(accounts, account_2) - assert Enum.member?(accounts, account_3) - - assert {:ok, accounts} = ChartOfAccountsServer.all_sorted_accounts("code") - account_1_index = find_account_index(accounts, "1001000") - account_2_index = find_account_index(accounts, "1002000") - account_3_index = find_account_index(accounts, "1003000") - assert account_2_index > account_1_index - assert account_3_index > account_2_index - - assert {:ok, accounts} = ChartOfAccountsServer.all_sorted_accounts("name") - account_1_index = find_account_index(accounts, "1001000") - account_2_index = find_account_index(accounts, "1002000") - account_3_index = find_account_index(accounts, "1003000") - assert account_2_index > account_1_index - assert account_3_index < account_2_index - - assert {:error, :invalid_field} = ChartOfAccountsServer.all_sorted_accounts("invalid") + describe "import/1" do + test "with a valid file" do + assert %{accounts: accounts, errors: _errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" + ) + + assert Enum.count(accounts) == 9 + end + + test "with a valid file twice" do + assert %{accounts: accounts, errors: errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" + ) + + assert %{accounts: [], errors: errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" + ) + + assert Enum.count(accounts) == 9 + assert Enum.count(errors) == 9 + assert Enum.all?(errors, fn error -> error.reason == :already_exists end) + + assert %{accounts: [], errors: errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/empty_chart_of_accounts_2.csv" + ) + end + + test "with an invalid file" do + assert {:error, :invalid_file} = + ChartOfAccounts.import_file("../../../../test/bookkeeping/data/invalid_file.csv") + + assert {:error, :invalid_file} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/empty_chart_of_accounts.csv" + ) + + assert {:error, :invalid_file} = + ChartOfAccounts.import_file("../../../../test/bookkeeping/data/text_file.txt") + + assert {:error, :invalid_file} = ChartOfAccounts.import_file(nil) + end + + test "with a file with invalid values" do + assert %{accounts: accounts, errors: errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/partially_valid_chart_of_accounts.csv" + ) + + assert Enum.count(accounts) == 7 + assert Enum.count(errors) == 3 + + assert Enum.all?(errors, fn error -> + error.reason in [:already_exists, :invalid_field] + end) + end end - test "reset accounts" do - assert {:ok, account_1} = - ChartOfAccountsServer.create_account("10010000101", "Cash5", "asset", "", %{}) - - assert {:ok, account_2} = - ChartOfAccountsServer.create_account( - "10020000101", - "Receivable5", - "asset", - "", - %{} - ) - - assert {:ok, account_3} = - ChartOfAccountsServer.create_account( - "10030000101", - "Inventory5", - "asset", - "", - %{} - ) - - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - assert Enum.member?(accounts, account_1) - assert Enum.member?(accounts, account_2) - assert Enum.member?(accounts, account_3) - - assert {:ok, []} = ChartOfAccountsServer.reset_accounts() - - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - refute Enum.member?(accounts, account_1) - refute Enum.member?(accounts, account_2) - refute Enum.member?(accounts, account_3) + describe "update/2" do + test "with valid params", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + + assert {:ok, updated_account} = + ChartOfAccounts.update(account, %{ + name: "Cash updated", + description: "description updated", + audit_details: %{updated_by: "example@example.com"}, + active: false + }) + + assert updated_account.code == "1000" + assert updated_account.name == "Cash updated" + assert updated_account.classification.name == "Asset" + assert updated_account.description == "description updated" + assert is_struct(updated_account.classification, Bookkeeping.Core.Account.Classification) + assert is_list(updated_account.audit_logs) + assert length(updated_account.audit_logs) == 2 + assert updated_account.active == false + end + + test "with invalid account" do + assert {:error, :invalid_account} = ChartOfAccounts.update(nil, %{name: "Cash updated"}) + assert {:error, :invalid_account} = ChartOfAccounts.update("apple", %{name: "Cash updated"}) + end + + test "with invalid field", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + + assert {:error, :invalid_field} = ChartOfAccounts.update(account, %{code: "1001"}) + + assert {:error, :invalid_field} = + ChartOfAccounts.update(account, %{classification: "liability"}) + + assert {:error, :invalid_field} = ChartOfAccounts.update(account, %{test: "test"}) + end + + test "with invalid params", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + + assert {:error, :invalid_params} = ChartOfAccounts.update(account, nil) + assert {:error, :invalid_params} = ChartOfAccounts.update(account, "apple") + assert {:error, :invalid_params} = ChartOfAccounts.update(account, %{}) + end end - test "get chart of accounts state" do - assert {:ok, state} = ChartOfAccountsServer.get_chart_of_accounts_state() - assert is_map(state) == true + describe "search_code/1" do + test "with complete code", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) + assert Enum.member?(accounts, account) + + assert {:ok, accounts} = ChartOfAccounts.search_code("10") + assert accounts == [account] + end + + test "with code prefix", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + assert {:ok, accounts} = ChartOfAccounts.search_code("10") + assert Enum.member?(accounts, account) + end + + test "with invalid code" do + assert {:error, :invalid_code} = ChartOfAccounts.search_code(nil) + assert {:error, :invalid_code} = ChartOfAccounts.search_code(%{}) + assert {:error, :invalid_code} = ChartOfAccounts.search_code("") + end end - test "test chart of accounts with working backup" do - assert {:ok, account_1} = - ChartOfAccountsServer.create_account( - "1001000010101", - "Cash6", - "asset", - "", - %{} - ) - - assert {:ok, account_2} = - ChartOfAccountsServer.create_account( - "1002000010101", - "Receivable6", - "asset", - "", - %{} - ) - - assert {:ok, account_3} = - ChartOfAccountsServer.create_account("1003000010101", "Inventory6", "asset", "", %{}) - - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - assert Enum.member?(accounts, account_1) - assert Enum.member?(accounts, account_2) - assert Enum.member?(accounts, account_3) - assert {:ok, backup} = ChartOfAccountsBackup.get() - assert backup == %{} - assert {:ok, :backup_updated} = ChartOfAccountsBackup.update(accounts) - assert {:ok, backup} = ChartOfAccountsBackup.get() - assert backup == accounts - assert {:ok, []} = ChartOfAccountsServer.reset_accounts() - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - refute Enum.member?(accounts, account_1) - refute Enum.member?(accounts, account_2) - refute Enum.member?(accounts, account_3) - assert {:ok, %{}} = ChartOfAccountsBackup.get() - - assert {:ok, :backup_updated} = ChartOfAccountsServer.terminate(:normal, %{}) + describe "search_name/1" do + test "with complete name", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + assert {:ok, accounts} = ChartOfAccounts.search_name(account.name) + assert Enum.member?(accounts, account) + + assert {:ok, accounts} = ChartOfAccounts.search_name("Cash") + assert accounts == [account] + end + + test "with name prefix", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + assert {:ok, accounts} = ChartOfAccounts.search_name("Ca") + assert Enum.member?(accounts, account) + end + + test "with invalid name" do + assert {:error, :invalid_name} = ChartOfAccounts.search_name(nil) + assert {:error, :invalid_name} = ChartOfAccounts.search_name(%{}) + assert {:error, :invalid_name} = ChartOfAccounts.search_name("") + end end - - defp find_account_index(accounts, code), do: Enum.find_index(accounts, &(&1.code == code)) end From dd035da8f58218b23cd833ef5a8c988c6248beff Mon Sep 17 00:00:00 2001 From: jeryldev Date: Sun, 10 Dec 2023 01:26:53 +0800 Subject: [PATCH 17/32] rework the chart of account and its tests --- .../boundary/chart_of_accounts/backup.ex | 50 -- .../boundary/chart_of_accounts/manager.ex | 65 ++ .../boundary/chart_of_accounts/server.ex | 601 ------------------ .../boundary/chart_of_accounts/supervisor.ex | 16 +- .../worker.ex | 29 +- .../boundary/chart_of_accounts_2/manager.ex | 52 -- .../chart_of_accounts_2/supervisor.ex | 29 - lib/bookkeeping/boundary/sample/manager.ex | 41 -- lib/bookkeeping/boundary/sample/supervisor.ex | 29 - lib/bookkeeping/boundary/sample/worker.ex | 46 -- .../boundary/chart_of_accounts_benchmark.exs | 132 ++-- .../boundary/chart_of_accounts_test.exs | 93 ++- 12 files changed, 224 insertions(+), 959 deletions(-) delete mode 100644 lib/bookkeeping/boundary/chart_of_accounts/backup.ex create mode 100644 lib/bookkeeping/boundary/chart_of_accounts/manager.ex delete mode 100644 lib/bookkeeping/boundary/chart_of_accounts/server.ex rename lib/bookkeeping/boundary/{chart_of_accounts_2 => chart_of_accounts}/worker.ex (92%) delete mode 100644 lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex delete mode 100644 lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex delete mode 100644 lib/bookkeeping/boundary/sample/manager.ex delete mode 100644 lib/bookkeeping/boundary/sample/supervisor.ex delete mode 100644 lib/bookkeeping/boundary/sample/worker.ex diff --git a/lib/bookkeeping/boundary/chart_of_accounts/backup.ex b/lib/bookkeeping/boundary/chart_of_accounts/backup.ex deleted file mode 100644 index cd47ab1..0000000 --- a/lib/bookkeeping/boundary/chart_of_accounts/backup.ex +++ /dev/null @@ -1,50 +0,0 @@ -defmodule Bookkeeping.Boundary.ChartOfAccounts.Backup do - @moduledoc """ - Bookkeeping.Boundary.ChartOfAccounts.Backup is responsible for storing the chart of accounts in a backup file. - """ - use Agent - - alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer - - @doc """ - Starts the ChartOfAccountsBackup agent. - - ## Examples - - iex> {:ok, pid} = Bookkeeping.Boundary.ChartOfAccounts.Backup.start_link() - {:ok, #PID<0.0.0>} - """ - @spec start_link(ChartOfAccountsServer.chart_of_account_state()) :: - {:error, any} | {:ok, pid} - def start_link(initial_value \\ %{}) do - Agent.start_link(fn -> initial_value end, name: __MODULE__) - end - - @doc """ - Gets the chart of accounts from the ChartOfAccountsBackup agent. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Backup.get() - {:ok, chart_of_accounts} - """ - @spec get :: {:ok, ChartOfAccountsServer.chart_of_account_state()} - def get do - chart_of_accounts = Agent.get(__MODULE__, fn state -> state end) - {:ok, chart_of_accounts} - end - - @doc """ - Updates the chart of accounts in the ChartOfAccountsBackup agent. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Backup.update(%{}) - {:ok, :backup_updated} - """ - @spec update(ChartOfAccountsServer.chart_of_account_state()) :: {:ok, :backup_updated} - def update(new_value) do - Agent.update(__MODULE__, fn _state -> new_value end) - {:ok, :backup_updated} - end -end diff --git a/lib/bookkeeping/boundary/chart_of_accounts/manager.ex b/lib/bookkeeping/boundary/chart_of_accounts/manager.ex new file mode 100644 index 0000000..ed7945d --- /dev/null +++ b/lib/bookkeeping/boundary/chart_of_accounts/manager.ex @@ -0,0 +1,65 @@ +defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do + use GenServer + + alias Bookkeeping.Boundary.ChartOfAccounts.Worker + + def start_link(_) do + case GenServer.start_link(__MODULE__, :ok, name: __MODULE__) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + error -> error + end + end + + def init(_) do + Process.flag(:trap_exit, true) + setup_table() + end + + def handle_info({:EXIT, _from, _reason}, table) do + {:noreply, table} + end + + def handle_info({:"ETS-TRANSFER", table, _pid, data}, _table) do + worker = wait_for_worker() + Process.link(worker) + :ets.give_away(table, worker, data) + {:noreply, table} + end + + def wait_for_worker() do + case Process.whereis(Worker) do + nil -> + Process.sleep(1) + wait_for_worker() + + pid -> + pid + end + end + + def setup_table() do + case Process.whereis(Worker) do + nil -> + Process.sleep(1) + setup_table() + + worker -> + Process.link(worker) + + table = + :ets.new(:chart_of_accounts, [ + :ordered_set, + :private, + write_concurrency: true, + read_concurrency: true + ]) + + data = {:count, 0} + :ets.insert(table, data) + :ets.setopts(table, {:heir, self(), data}) + :ets.give_away(table, worker, data) + {:ok, table} + end + end +end diff --git a/lib/bookkeeping/boundary/chart_of_accounts/server.ex b/lib/bookkeeping/boundary/chart_of_accounts/server.ex deleted file mode 100644 index f7bd07f..0000000 --- a/lib/bookkeeping/boundary/chart_of_accounts/server.ex +++ /dev/null @@ -1,601 +0,0 @@ -defmodule Bookkeeping.Boundary.ChartOfAccounts.Server do - @moduledoc """ - Bookkeeping.Boundary.ChartOfAccounts.Server is a GenServer that manages the chart of accounts. - Chart of Accounts is a list of all accounts used by a business. - The Chart Of Accounts GenServer is responsible for creating, updating, and searching accounts. - The state of the Chart Of Accounts GenServer is a map in which the keys are the account codes and the values are the account structs. - """ - use GenServer - - alias Bookkeeping.Boundary.ChartOfAccounts.Backup, as: ChartOfAccountsBackup - alias Bookkeeping.Core.Account - alias NimbleCSV.RFC4180, as: CSV - - @typedoc """ - The state of the Chart Of Accounts GenServer. - The state is a map in which the keys are the account codes and the values are the account structs. - - ## Examples - - iex> %{ - ...> "1000" => %Account{ - ...> id: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", - ...> code: "1000", - ...> name: "Cash", - ...> description: "", - ...> classification: %AccountClassification{ - ...> name: "Asset", - ...> normal_balance: :debit, - ...> category: :position, - ...> contra: false - ...> }, - ...> active: true, - ...> audit_logs: [ - ...> %AuditLog{ - ...> id: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", - ...> record_type: "account", - ...> action_type: "create", - ...> details: %{}, - ...> created_at: 1633860610, - ...> updated_at: 1633860610, - ...> deleted_at: nil - ...> } - ...> ] - ...> }, - ...> ... - ...> } - """ - @type chart_of_account_state :: %{Account.account_code() => Account.t()} - @type chart_of_accounts_server_pid :: atom | pid | {atom, any} | {:via, atom, any} - - @classifications [ - "asset", - "liability", - "equity", - "revenue", - "expense", - "gain", - "loss", - "contra_asset", - "contra_liability", - "contra_equity", - "contra_revenue", - "contra_expense", - "contra_gain", - "contra_loss" - ] - - @doc """ - Starts the Chart of Accounts GenServer. - - Returns `{:ok, pid}` if the GenServer is started successfully. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.start_link() - {:ok, #PID<0.123.0>} - """ - @spec start_link(Keyword.t()) :: {:ok, pid} - def start_link(options \\ []) do - GenServer.start_link(__MODULE__, %{}, options) - end - - @doc """ - Creates a new account. - - Arguments: - - code: The unique code of the account. - - name: The unique name of the account. - - classification: The classification of the account. The account classification must be one of the following: `"asset"`, `"liability"`, `"equity"`, `"revenue"`, `"expense"`, `"gain"`, `"loss"`, `"contra_asset"`, `"contra_liability"`, `"contra_equity"`, `"contra_revenue"`, `"contra_expense"`, `"contra_gain"`, `"contra_loss"`. - - description: The description of the account. - - audit_details: The audit details of the account. - - Returns `{:ok, account}` if the account is valid, otherwise `{:error, :invalid_account}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.create_account(server, "1000", "Cash", "asset", "", %{}) - {:ok, %Bookkeeping.Core.Account{...}} - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.create_account(server, "invalid", "invalid", nil, false, %{}) - {:error, :invalid_account} - """ - @spec create_account( - chart_of_accounts_server_pid(), - String.t(), - String.t(), - String.t(), - String.t(), - map() - ) :: {:ok, Account.t()} | {:error, :invalid_account} | {:error, :account_already_exists} - def create_account( - server \\ __MODULE__, - code, - name, - classification, - description, - audit_details - ) do - create_account_record(server, code, name, classification, description, audit_details) - end - - @doc """ - Imports default accounts from a CSV file. - The headers of the CSV file must be `Account Code`, `Account Name`, `Account Type`, `Description`, and `Audit Details`. - - Arguments: - - path: The path of the CSV file. The path to the default accounts is "../data/sample_chart_of_accounts.csv". - - Returns `{:ok, %{ok: list(map()), error: list(map())}}` if the accounts are imported successfully. If all items are encountered an error, return `{:error, %{ok: list(map()), error: list(map())}}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.import_accounts(server, "../data/sample_chart_of_accounts.csv") - {:ok, - %{ - ok: [ - %{account_code: "1000", account_name: "Cash"}, - %{account_code: "1010", account_name: "Petty Cash"}, - %{account_code: "1020", account_name: "Cash on Hand"}, - %{account_code: "1030", account_name: "Cash in Bank"} - ], - error: [] - }} - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.import_accounts(server, "../data/invalid_chart_of_accounts.csv") - {:error, - %{ - ok: [], - error: [ - %{ - account_code: "1001", - account_name: "Cash", - error: :account_already_exists - }, - %{ - account_code: "1002", - account_name: "Cash", - error: :invalid_account - }, - ... - ] - }} - """ - @spec import_accounts(chart_of_accounts_server_pid(), String.t()) :: - {:ok, %{ok: list(Account.t()), error: list(map())}} - | {:error, %{ok: list(Account.t()), error: list(map())}} - | {:error, %{message: :invalid_csv, errors: list(map())}} - | {:error, :invalid_file} - def import_accounts(server \\ __MODULE__, path) do - with file_path <- Path.expand(path, __DIR__), - true <- File.exists?(file_path), - {:ok, csv} <- read_csv(file_path) do - bulk_create_accounts(server, csv) - else - _error -> {:error, :invalid_file} - end - end - - @doc """ - Updates an account. - - Arguments: - - account: The account to be updated. - - attrs: The attributes to be updated. The editable attributes are `name`, `description`, `active`, and `audit_details`. - - Returns `{:ok, account}` if the account is valid, otherwise `{:error, :invalid_account}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.update_account(server, account, %{name: "Cash and cash equivalents"}) - {:ok, %Bookkeeping.Core.Account{...}} - """ - @spec update_account(chart_of_accounts_server_pid(), Account.t(), map()) :: - {:ok, Account.t()} | {:error, :invalid_account} - def update_account(server \\ __MODULE__, account, attrs) do - GenServer.call(server, {:update_account, account, attrs}) - end - - @doc """ - Returns all accounts. - - Returns `{:ok, accounts}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.all_accounts(server) - {:ok, [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...]} - """ - @spec all_accounts(chart_of_accounts_server_pid()) :: {:ok, list(Account.t())} - def all_accounts(server \\ __MODULE__) do - GenServer.call(server, :all_accounts) - end - - @doc """ - Finds an account by code. - - Arguments: - - code: The unique code of the account. - - Returns `{:ok, account}` if the account was found, otherwise `{:error, :not_found}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.find_account_by_code(server, "1000") - {:ok, %Bookkeeping.Core.Account{...}} - """ - @spec find_account_by_code(chart_of_accounts_server_pid(), String.t()) :: - {:ok, Account.t()} | {:error, :not_found} - def find_account_by_code(server \\ __MODULE__, code) - - def find_account_by_code(server, code) when is_binary(code), - do: GenServer.call(server, {:find_account_by_code, code}) - - def find_account_by_code(_server, _code), do: {:error, :invalid_code} - - @doc """ - Finds an account by name. - - Arguments: - - name: The unique name of the account. - - Returns `{:ok, account}` if the account was found, otherwise `{:error, :not_found}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.find_account_by_name(server, "Cash") - {:ok, %Bookkeeping.Core.Account{...}} - """ - @spec find_account_by_name(chart_of_accounts_server_pid(), String.t()) :: - {:ok, Account.t()} | {:error, :not_found} - def find_account_by_name(server \\ __MODULE__, name) - - def find_account_by_name(server, name) when is_binary(name), - do: GenServer.call(server, {:find_account_by_name, name}) - - def find_account_by_name(_server, _name), do: {:error, :invalid_name} - - @doc """ - Search accounts by code or name. - - Arguments: - - query: The query to search for code or name. - - Returns `{:ok, accounts}` if the account was found, otherwise `{:ok, []}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.search_accounts(server, "1000") - {:ok, [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...]} - """ - @spec search_accounts(chart_of_accounts_server_pid(), String.t()) :: - {:ok, list(Account.t())} | {:error, :invalid_query} - def search_accounts(server \\ __MODULE__, query) - - def search_accounts(server, query) when is_binary(query), - do: GenServer.call(server, {:search_accounts, query}) - - def search_accounts(_server, _query), do: {:error, :invalid_query} - - @doc """ - Get all accounts sorted by code or name. - - Returns `{:ok, accounts}` if the accounts were sorted successfully, otherwise `{:error, :invalid_field}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.all_sorted_accounts(server, :code) - {:ok, [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...]} - """ - @spec all_sorted_accounts(chart_of_accounts_server_pid(), String.t()) :: - {:ok, list(Account.t())} | {:error, :invalid_field} - def all_sorted_accounts(server \\ __MODULE__, field) - def all_sorted_accounts(server, "code"), do: GenServer.call(server, {:sort_accounts, :code}) - def all_sorted_accounts(server, "name"), do: GenServer.call(server, {:sort_accounts, :name}) - def all_sorted_accounts(_server, _field), do: {:error, :invalid_field} - - @doc """ - Resets the accounts. - - Returns `{:ok, []}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.reset_accounts(server) - {:ok, []} - """ - @spec reset_accounts(chart_of_accounts_server_pid()) :: {:ok, list(Account.t())} - def reset_accounts(server \\ __MODULE__) do - GenServer.call(server, :reset_accounts) - end - - @doc """ - Returns the state of the Chart Of Accounts GenServer. - - Returns `{:ok, state}`. - - ## Examples - - iex> Bookkeeping.Boundary.ChartOfAccounts.Server.get_chart_of_accounts_state(server) - {:ok, %{...}} - """ - @spec get_chart_of_accounts_state(chart_of_accounts_server_pid()) :: - {:ok, chart_of_account_state()} - def get_chart_of_accounts_state(server \\ __MODULE__) do - GenServer.call(server, :get_chart_of_accounts_state) - end - - @impl true - @spec init(chart_of_account_state()) :: {:ok, chart_of_account_state()} - def init(_chart_of_accounts) do - ChartOfAccountsBackup.get() - end - - @impl true - def handle_call( - {:create_account, code, name, classification, description, audit_details}, - _from, - accounts - ) do - case Account.create(code, name, classification, description, audit_details) do - {:ok, account} -> - updated_accounts = Map.put(accounts, code, account) - {:reply, {:ok, account}, updated_accounts, :hibernate} - - {:error, message} -> - {:reply, {:error, message}, accounts, :hibernate} - end - end - - @impl true - def handle_call({:update_account, account, attrs}, _from, accounts) do - with {:ok, account} <- Account.validate(account), - {:ok, updated_account} <- Account.update(account, attrs) do - updated_accounts = - accounts - |> Map.delete(account.code) - |> Map.put(updated_account.code, updated_account) - - {:reply, {:ok, updated_account}, updated_accounts, :hibernate} - else - _ -> - {:reply, {:error, :invalid_account}, accounts, :hibernate} - end - end - - @impl true - def handle_call(:all_accounts, from, accounts) do - Task.async(fn -> GenServer.reply(from, {:ok, Map.values(accounts)}) end) - {:noreply, accounts} - end - - @impl true - def handle_call({:find_account_by_code, code}, from, accounts) do - Task.async(fn -> - case Map.get(accounts, code) do - nil -> GenServer.reply(from, {:error, :not_found}) - account -> GenServer.reply(from, {:ok, account}) - end - end) - - {:noreply, accounts, :hibernate} - end - - @impl true - def handle_call({:find_account_by_name, name}, from, accounts) do - Task.async(fn -> - result = - Enum.find(accounts, fn {_code, account} -> - String.downcase(account.name) == String.downcase(name) - end) - - case result do - nil -> GenServer.reply(from, {:error, :not_found}) - {_code, account} -> GenServer.reply(from, {:ok, account}) - end - end) - - {:noreply, accounts, :hibernate} - end - - @impl true - def handle_call({:search_accounts, binary_query}, from, accounts) do - Task.async(fn -> - found_accounts = - accounts - |> Task.async_stream(fn {code, account} -> - query = String.downcase(binary_query) - name = String.downcase(account.name) - found? = String.contains?(code, query) or String.contains?(name, query) - {found?, account} - end) - |> Enum.reduce([], fn - {:ok, {true, account}}, acc -> acc ++ [account] - _, acc -> acc - end) - - GenServer.reply(from, {:ok, found_accounts}) - end) - - {:noreply, accounts} - end - - @impl true - def handle_call({:sort_accounts, field}, from, accounts) do - Task.async(fn -> - sorted_accounts = Enum.sort_by(Map.values(accounts), &Map.get(&1, field)) - GenServer.reply(from, {:ok, sorted_accounts}) - end) - - {:noreply, accounts} - end - - @impl true - def handle_call(:reset_accounts, _from, _chart_of_accounts) do - ChartOfAccountsBackup.update(%{}) - {:reply, {:ok, []}, %{}} - end - - @impl true - def handle_call(:get_chart_of_accounts_state, from, accounts) do - Task.async(fn -> GenServer.reply(from, {:ok, accounts}) end) - {:noreply, accounts} - end - - @impl true - def handle_info(_msg, state) do - {:noreply, state} - end - - @impl true - def terminate(_reason, chart_of_accounts) do - ChartOfAccountsBackup.update(chart_of_accounts) - end - - defp create_account_record( - server, - code, - name, - classification, - description, - audit_details - ) do - valid_fields? = - is_binary(code) and is_binary(name) and classification in @classifications - - with true <- valid_fields?, - {:error, :not_found} <- GenServer.call(server, {:find_account_by_code, code}), - {:error, :not_found} <- GenServer.call(server, {:find_account_by_name, name}) do - GenServer.call( - server, - {:create_account, code, name, classification, description, audit_details} - ) - else - {:ok, _account} -> {:error, :account_already_exists} - _ -> {:error, :invalid_account} - end - end - - defp bulk_create_accounts(server, csv) when is_list(csv) and csv != [] do - with %{ok: ok_create_params, error: []} <- generate_bulk_create_params(csv), - {:ok, result} <- bulk_create_acc_records(server, ok_create_params) do - {:ok, result} - else - %{ok: _ok_create_params, error: errors} -> - {:error, %{message: :invalid_csv, errors: errors}} - - {:error, result} -> - {:error, result} - end - end - - defp bulk_create_accounts(_server, _csv), do: {:error, :invalid_file} - - defp bulk_create_acc_records(server, create_params_list) do - result = - Enum.reduce( - create_params_list, - %{ok: [], error: []}, - fn params, acc -> - case create_account_record( - server, - params.account_code, - params.account_name, - params.classification, - params.description, - params.audit_details - ) do - {:ok, account} -> - Map.put(acc, :ok, [account | acc.ok]) - - {:error, error} -> - errors = - acc.error ++ - [ - %{ - account_code: params.account_code, - account_name: params.account_name, - error: error - } - ] - - Map.put(acc, :error, errors) - end - end - ) - - if result.ok == [], do: {:error, result}, else: {:ok, result} - end - - defp generate_bulk_create_params(csv) do - Enum.reduce( - csv, - %{ok: [], error: []}, - fn csv_item, acc -> - account_code = Map.get(csv_item, "Account Code") - account_name = Map.get(csv_item, "Account Name") - classification = Map.get(csv_item, "Account Type") - description = Map.get(csv_item, "Account Description", "") - audit_details = Map.get(csv_item, "Audit Details", "{}") - - valid_csv_items? = - is_binary(account_code) and account_code != "" and is_binary(account_name) and - account_name != "" and is_binary(description) and - classification in @classifications - - with true <- valid_csv_items?, - {:ok, audit_details} <- Jason.decode(audit_details) do - valid_params = %{ - account_code: account_code, - account_name: account_name, - classification: classification, - description: description, - audit_details: audit_details - } - - Map.put(acc, :ok, acc.ok ++ [valid_params]) - else - {:error, %Jason.DecodeError{} = _error} -> - errors = - acc.error ++ - [ - %{ - account_code: account_code, - account_name: account_name, - error: :invalid_csv_item - } - ] - - Map.put(acc, :error, errors) - - _ -> - errors = - acc.error ++ - [ - %{ - account_code: account_code, - account_name: account_name, - error: :invalid_csv_item - } - ] - - Map.put(acc, :error, errors) - end - end - ) - end - - defp read_csv(path) do - csv_inputs = - path - |> File.stream!() - |> CSV.parse_stream(skip_headers: false) - |> Stream.transform(nil, fn - headers, nil -> {[], headers} - row, headers -> {[Enum.zip(headers, row) |> Map.new()], headers} - end) - |> Enum.to_list() - - {:ok, csv_inputs} - end -end diff --git a/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex b/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex index 28981f8..c501e8c 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex @@ -1,12 +1,8 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Supervisor do - @moduledoc """ - Bookkeeping.Boundary.ChartOfAccounts.Supervisor is responsible for starting the ChartOfAccountsServer and ChartOfAccountsBackup processes. - It is also responsible for restarting the ChartOfAccountsServer process if it crashes. - """ use Supervisor - alias Bookkeeping.Boundary.ChartOfAccounts.Backup, as: ChartOfAccountsBackup - alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer + alias Bookkeeping.Boundary.ChartOfAccounts.Worker + alias Bookkeeping.Boundary.ChartOfAccounts.Manager @type init_options_t :: list() @type sup_flags_t :: map() @@ -14,18 +10,20 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Supervisor do @spec start_link(init_options_t()) :: {:ok, pid} | {:error, {:already_started, pid()} | {:shutdown, term()} | term()} + def start_link(options \\ []) do Supervisor.start_link(__MODULE__, :ok, options) end @impl true + @spec init(any()) :: {:ok, {sup_flags_t(), children_specs_t}} def init(_init_arg) do children = [ - {ChartOfAccountsBackup, %{}}, - {ChartOfAccountsServer, [name: ChartOfAccountsServer]} + {Worker, [name: Worker]}, + {Manager, %{}} ] - Supervisor.init(children, strategy: :rest_for_one) + Supervisor.init(children, strategy: :one_for_one) end end diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex similarity index 92% rename from lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex rename to lib/bookkeeping/boundary/chart_of_accounts/worker.ex index bcacd9f..5c4ecf2 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts_2/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -1,4 +1,4 @@ -defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do +defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do use GenServer alias Bookkeeping.Core.Account @@ -50,33 +50,52 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts2.Worker do GenServer.call(__MODULE__, {:search_name, name}) end + @spec die() :: :ok + def die do + GenServer.cast(__MODULE__, :die) + end + @spec init(any()) :: {:ok, nil} + @impl true def init(_) do {:ok, nil} end + @impl true def handle_info({:"ETS-TRANSFER", table, _pid, _data}, _table) do {:noreply, table} end + @impl true + def handle_info(_msg, state) do + {:noreply, state} + end + + @impl true def handle_call({:create, params}, _from, table) do - result = create(table, params) - {:reply, result, table} + {:reply, create(table, params), table} end + @impl true def handle_call({:update, account, params}, _from, table) do - result = update(table, account, params) - {:reply, result, table} + {:reply, update(table, account, params), table} end + @impl true def handle_call({:search_code, prefix}, _from, table) do {:reply, prefix_search_code(table, prefix), table} end + @impl true def handle_call({:search_name, prefix}, _from, table) do {:reply, prefix_search_name(table, prefix), table} end + @impl true + def handle_cast(:die, table) do + {:stop, table, :killed} + end + defp create(table, params) do with {:ok, account} <- Account.create(params), {:error, :not_found} <- check_similar_account(table, account) do diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex deleted file mode 100644 index 9536f22..0000000 --- a/lib/bookkeeping/boundary/chart_of_accounts_2/manager.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule Bookkeeping.Boundary.ChartOfAccounts2.Manager do - use GenServer - - alias Bookkeeping.Boundary.ChartOfAccounts2.Worker - - def start_link(_) do - case GenServer.start_link(__MODULE__, :ok, name: __MODULE__) do - {:ok, pid} -> {:ok, pid} - {:error, {:already_started, pid}} -> {:ok, pid} - end - end - - def init(_) do - Process.flag(:trap_exit, true) - worker = Process.whereis(Worker) - Process.link(worker) - - table = - :ets.new(:give_away, [ - :ordered_set, - :private, - write_concurrency: true, - read_concurrency: true - ]) - - data = {:count, 0} - :ets.insert(table, data) - :ets.setopts(table, {:heir, self(), data}) - :ets.give_away(table, worker, data) - {:ok, table} - end - - def handle_info({:EXIT, _from, _reason}, table), do: {:noreply, table} - - def handle_info({:"ETS-TRANSFER", table, _pid, data}, _table) do - worker = wait_for_worker() - Process.link(worker) - :ets.give_away(table, worker, data) - {:noreply, table} - end - - def wait_for_worker() do - case Process.whereis(Worker) do - nil -> - Process.sleep(1) - wait_for_worker() - - pid -> - pid - end - end -end diff --git a/lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex b/lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex deleted file mode 100644 index f275288..0000000 --- a/lib/bookkeeping/boundary/chart_of_accounts_2/supervisor.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Bookkeeping.Boundary.ChartOfAccounts2.Supervisor do - use Supervisor - - alias Bookkeeping.Boundary.ChartOfAccounts2.Worker - alias Bookkeeping.Boundary.ChartOfAccounts2.Manager - - @type init_options_t :: list() - @type sup_flags_t :: map() - @type children_specs_t :: list(:supervisor.child_spec()) - - @spec start_link(init_options_t()) :: - {:ok, pid} | {:error, {:already_started, pid()} | {:shutdown, term()} | term()} - - def start_link(options \\ []) do - Supervisor.start_link(__MODULE__, :ok, options) - end - - @impl true - - @spec init(any()) :: {:ok, {sup_flags_t(), children_specs_t}} - def init(_init_arg) do - children = [ - {Worker, [name: Worker]}, - {Manager, %{}} - ] - - Supervisor.init(children, strategy: :one_for_one) - end -end diff --git a/lib/bookkeeping/boundary/sample/manager.ex b/lib/bookkeeping/boundary/sample/manager.ex deleted file mode 100644 index 35f91d1..0000000 --- a/lib/bookkeeping/boundary/sample/manager.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Bookkeeping.Boundary.Sample.Manager do - use GenServer - - alias Bookkeeping.Boundary.Sample.Worker - - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - def init(_) do - Process.flag(:trap_exit, true) - worker = Process.whereis(Worker) - Process.link(worker) - table = :ets.new(:give_away, [:private]) - data = {:count, 0} - :ets.insert(table, data) - :ets.setopts(table, {:heir, self(), data}) - :ets.give_away(table, worker, data) - {:ok, table} - end - - def handle_info({:EXIT, _from, _reason}, table), do: {:noreply, table} - - def handle_info({:"ETS-TRANSFER", table, _pid, data}, _table) do - worker = wait_for_worker() - Process.link(worker) - :ets.give_away(table, worker, data) - {:noreply, table} - end - - def wait_for_worker() do - case Process.whereis(Worker) do - nil -> - Process.sleep(1) - wait_for_worker() - - pid -> - pid - end - end -end diff --git a/lib/bookkeeping/boundary/sample/supervisor.ex b/lib/bookkeeping/boundary/sample/supervisor.ex deleted file mode 100644 index c64e81f..0000000 --- a/lib/bookkeeping/boundary/sample/supervisor.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Bookkeeping.Boundary.Sample.Supervisor do - use Supervisor - - alias Bookkeeping.Boundary.Sample.Manager, as: SampleManager - alias Bookkeeping.Boundary.Sample.Worker, as: SampleWorker - - @type init_options_t :: list() - @type sup_flags_t :: map() - @type children_specs_t :: list(:supervisor.child_spec()) - - @spec start_link(init_options_t()) :: - {:ok, pid} | {:error, {:already_started, pid()} | {:shutdown, term()} | term()} - - def start_link(options \\ []) do - Supervisor.start_link(__MODULE__, :ok, options) - end - - @impl true - - @spec init(any()) :: {:ok, {sup_flags_t(), children_specs_t}} - def init(_init_arg) do - children = [ - {SampleWorker, [name: SampleWorker]}, - {SampleManager, %{}} - ] - - Supervisor.init(children, strategy: :one_for_one) - end -end diff --git a/lib/bookkeeping/boundary/sample/worker.ex b/lib/bookkeeping/boundary/sample/worker.ex deleted file mode 100644 index e736456..0000000 --- a/lib/bookkeeping/boundary/sample/worker.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Bookkeeping.Boundary.Sample.Worker do - use GenServer - - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - def init(_) do - {:ok, nil} - end - - def handle_info({:"ETS-TRANSFER", table, _pid, _data}, _table) do - {:noreply, table} - end - - def handle_call({:get, key}, _from, table) do - case :ets.lookup(table, key) do - [] -> - {:reply, nil, table} - - [{_key, value}] -> - {:reply, value, table} - end - end - - def handle_call({:put, key, value}, _from, table) do - result = :ets.insert(table, {key, value}) - {:reply, result, table} - end - - def handle_cast(:die, table) do - {:stop, table, :killed} - end - - def get(key) do - GenServer.call(__MODULE__, {:get, key}) - end - - def put(key, value) do - GenServer.call(__MODULE__, {:put, key, value}) - end - - def die() do - GenServer.cast(__MODULE__, :die) - end -end diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs index b2e798c..990babd 100644 --- a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs @@ -1,73 +1,73 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer - alias Bookkeeping.Boundary.ChartOfAccounts2.Supervisor, as: ChartOfAccounts2Supervisor - alias Bookkeeping.Boundary.ChartOfAccounts2.Worker + alias Bookkeeping.Boundary.ChartOfAccounts.Supervisor, as: ChartOfAccountsSupervisor + alias Bookkeeping.Boundary.ChartOfAccounts.Worker ChartOfAccountsServer.start_link() - ChartOfAccounts2Supervisor.start_link() - - Benchee.run(%{ - "COA Server create/5" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - ChartOfAccountsServer.create_account( - random_string, - random_string, - "asset", - "Cash and Cash Equivalents 0", - %{} - ) - end, - "COA Worker create/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - params = %{ - code: random_string, - name: random_string, - classification: "asset", - description: "Cash and Cash Equivalents 0", - audit_details: %{}, - active: true - } - - Worker.create(params) - end - }) - - Benchee.run(%{ - "COA Server find_account_by_code/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - ChartOfAccountsServer.find_account_by_code(random_string) - end, - "COA Worker search_code/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - Worker.search_code(random_string) - end - }) - - Benchee.run(%{ - "COA Server find_account_by_name/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - ChartOfAccountsServer.find_account_by_name(random_string) - end, - "COA Worker search_name/1" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - Worker.search_name(random_string) - end - }) - - Benchee.run(%{ - "COA Server import_accounts/1" => fn -> - ChartOfAccountsServer.import_accounts("../../data/sample_chart_of_accounts.csv") - end, - "COA Worker import_file/1" => fn -> - Worker.import_file("../../data/sample_chart_of_accounts.csv") - end - }) + ChartOfAccountsSupervisor.start_link() + + # Benchee.run(%{ + # "COA Server create/5" => fn -> + # random_string = for _ <- 1..10, into: "", do: <> + + # ChartOfAccountsServer.create_account( + # random_string, + # random_string, + # "asset", + # "Cash and Cash Equivalents 0", + # %{} + # ) + # end, + # "COA Worker create/1" => fn -> + # random_string = for _ <- 1..10, into: "", do: <> + + # params = %{ + # code: random_string, + # name: random_string, + # classification: "asset", + # description: "Cash and Cash Equivalents 0", + # audit_details: %{}, + # active: true + # } + + # Worker.create(params) + # end + # }) + + # Benchee.run(%{ + # "COA Server find_account_by_code/1" => fn -> + # random_string = for _ <- 1..10, into: "", do: <> + + # ChartOfAccountsServer.find_account_by_code(random_string) + # end, + # "COA Worker search_code/1" => fn -> + # random_string = for _ <- 1..10, into: "", do: <> + + # Worker.search_code(random_string) + # end + # }) + + # Benchee.run(%{ + # "COA Server find_account_by_name/1" => fn -> + # random_string = for _ <- 1..10, into: "", do: <> + + # ChartOfAccountsServer.find_account_by_name(random_string) + # end, + # "COA Worker search_name/1" => fn -> + # random_string = for _ <- 1..10, into: "", do: <> + + # Worker.search_name(random_string) + # end + # }) + + # Benchee.run(%{ + # "COA Server import_accounts/1" => fn -> + # ChartOfAccountsServer.import_accounts("../../data/sample_chart_of_accounts.csv") + # end, + # "COA Worker import_file/1" => fn -> + # Worker.import_file("../../data/sample_chart_of_accounts.csv") + # end + # }) Benchee.run(%{ "COA Server update/2" => fn -> diff --git a/test/bookkeeping/boundary/chart_of_accounts_test.exs b/test/bookkeeping/boundary/chart_of_accounts_test.exs index c28bfe0..9981472 100644 --- a/test/bookkeeping/boundary/chart_of_accounts_test.exs +++ b/test/bookkeeping/boundary/chart_of_accounts_test.exs @@ -1,7 +1,6 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do use ExUnit.Case - alias Bookkeeping.Boundary.ChartOfAccounts2.Worker, as: ChartOfAccounts - alias Bookkeeping.Boundary.ChartOfAccounts2.Supervisor, as: ChartOfAccountsSupervisor + alias Bookkeeping.Boundary.ChartOfAccounts.Worker, as: ChartOfAccounts setup do params = %{ @@ -22,9 +21,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do active: true } - {:ok, server_pid} = ChartOfAccountsSupervisor.start_link([]) - - {:ok, params: params, invalid_params: invalid_params, server_pid: server_pid} + {:ok, params: params, invalid_params: invalid_params} end describe "Worker start_link/1 " do @@ -34,15 +31,16 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do end test "with invalid params" do - {:ok, server} = ChartOfAccounts.start_link(test: nil) + {:ok, _server} = ChartOfAccounts.start_link(test: nil) end end describe "create/1" do test "with valid params", %{params: params} do + params = update_params(params) assert {:ok, account} = ChartOfAccounts.create(params) - assert account.code == "1000" - assert account.name == "Cash" + assert account.code == params.code + assert account.name == params.name assert account.classification.name == "Asset" assert account.description == "description" assert is_struct(account.classification, Bookkeeping.Core.Account.Classification) @@ -63,42 +61,38 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert {:error, :invalid_params} = ChartOfAccounts.create(%{active: true}) end - test "with invalid field", %{params: params, invalid_params: invalid_params} do - assert {:error, :invalid_field} = ChartOfAccounts.create(invalid_params) + test "with invalid field", %{invalid_params: invalid_params} do + params = update_params(invalid_params) + assert {:error, :invalid_field} = ChartOfAccounts.create(params) end test "that already exists", %{params: params} do - assert {:ok, account} = ChartOfAccounts.create(params) + params = update_params(params) + assert {:ok, _account} = ChartOfAccounts.create(params) assert {:error, :already_exists} = ChartOfAccounts.create(params) end end describe "import/1" do - test "with a valid file" do + test "with a valid file twice" do assert %{accounts: accounts, errors: _errors} = ChartOfAccounts.import_file( "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" ) - assert Enum.count(accounts) == 9 - end + assert length(accounts) == 9 - test "with a valid file twice" do - assert %{accounts: accounts, errors: errors} = - ChartOfAccounts.import_file( - "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" - ) + Process.sleep(300) assert %{accounts: [], errors: errors} = ChartOfAccounts.import_file( "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" ) - assert Enum.count(accounts) == 9 assert Enum.count(errors) == 9 assert Enum.all?(errors, fn error -> error.reason == :already_exists end) - assert %{accounts: [], errors: errors} = + assert %{accounts: [], errors: _errors} = ChartOfAccounts.import_file( "../../../../test/bookkeeping/data/empty_chart_of_accounts_2.csv" ) @@ -136,6 +130,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do describe "update/2" do test "with valid params", %{params: params} do + params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) assert {:ok, updated_account} = @@ -146,7 +141,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do active: false }) - assert updated_account.code == "1000" + assert updated_account.code == account.code assert updated_account.name == "Cash updated" assert updated_account.classification.name == "Asset" assert updated_account.description == "description updated" @@ -162,6 +157,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do end test "with invalid field", %{params: params} do + params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) assert {:error, :invalid_field} = ChartOfAccounts.update(account, %{code: "1001"}) @@ -173,6 +169,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do end test "with invalid params", %{params: params} do + params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) assert {:error, :invalid_params} = ChartOfAccounts.update(account, nil) @@ -183,17 +180,21 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do describe "search_code/1" do test "with complete code", %{params: params} do + params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) assert Enum.member?(accounts, account) - - assert {:ok, accounts} = ChartOfAccounts.search_code("10") - assert accounts == [account] + code_prefix = String.slice(account.code, 0, 2) + assert {:ok, accounts} = ChartOfAccounts.search_code(code_prefix) + assert account in accounts end test "with code prefix", %{params: params} do + params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) - assert {:ok, accounts} = ChartOfAccounts.search_code("10") + + code_prefix = String.slice(account.code, 0, 2) + assert {:ok, accounts} = ChartOfAccounts.search_code(code_prefix) assert Enum.member?(accounts, account) end @@ -206,18 +207,21 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do describe "search_name/1" do test "with complete name", %{params: params} do + params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) assert {:ok, accounts} = ChartOfAccounts.search_name(account.name) assert Enum.member?(accounts, account) - - assert {:ok, accounts} = ChartOfAccounts.search_name("Cash") - assert accounts == [account] + name_prefix = String.slice(account.name, 0, 2) + assert {:ok, accounts} = ChartOfAccounts.search_name(name_prefix) + assert account in accounts end test "with name prefix", %{params: params} do + params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) - assert {:ok, accounts} = ChartOfAccounts.search_name("Ca") - assert Enum.member?(accounts, account) + name_prefix = String.slice(account.name, 0, 2) + assert {:ok, accounts} = ChartOfAccounts.search_name(name_prefix) + assert account in accounts end test "with invalid name" do @@ -226,4 +230,31 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert {:error, :invalid_name} = ChartOfAccounts.search_name("") end end + + describe "Worker die/0" do + test "still restores the state of the table", %{params: params} do + params = update_params(params) + assert {:ok, account} = ChartOfAccounts.create(params) + assert is_struct(account) + + ChartOfAccounts.die() + + # We need to add another test and feature that will allow ChartOfAccounts to + # delay the process calls until the worker is ready again. + Process.sleep(300) + + assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) + assert account in accounts + end + end + + defp update_params(params) do + code = random_string() + name = random_string() + Map.merge(params, %{code: code, name: name}) + end + + defp random_string do + for _ <- 1..10, into: "", do: <> + end end From 21eedb242d3ce06f04523a9d57a29e7cf205f741 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Sun, 10 Dec 2023 01:49:06 +0800 Subject: [PATCH 18/32] rename line account description --- lib/bookkeeping.ex | 2 +- .../boundary/accounting_journal/server.ex | 24 +++++++++---------- .../boundary/chart_of_accounts/manager.ex | 9 +++++-- .../boundary/chart_of_accounts/supervisor.ex | 3 +-- .../boundary/chart_of_accounts/worker.ex | 11 +++++---- lib/bookkeeping/core/journal_entry.ex | 4 ++-- lib/bookkeeping/core/line_item.ex | 14 +++++------ .../boundary/chart_of_accounts_benchmark.exs | 1 - .../boundary/accounting_journal_test.exs | 16 ++++++------- test/bookkeeping/core/journal_entry_test.exs | 4 ++-- test/bookkeeping/core/line_item_test.exs | 6 ++--- 11 files changed, 49 insertions(+), 45 deletions(-) diff --git a/lib/bookkeeping.ex b/lib/bookkeeping.ex index 0f6b686..29f0eef 100644 --- a/lib/bookkeeping.ex +++ b/lib/bookkeeping.ex @@ -11,7 +11,7 @@ defmodule Bookkeeping do """ alias Bookkeeping.Boundary.AccountingJournal.Server, as: AccountingJournal - alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccounts + alias Bookkeeping.Boundary.ChartOfAccounts.Worker, as: ChartOfAccounts alias Bookkeeping.Core.{Account, JournalEntry} ########################################################## diff --git a/lib/bookkeeping/boundary/accounting_journal/server.ex b/lib/bookkeeping/boundary/accounting_journal/server.ex index 281c21a..61dbb33 100644 --- a/lib/bookkeeping/boundary/accounting_journal/server.ex +++ b/lib/bookkeeping/boundary/accounting_journal/server.ex @@ -706,7 +706,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do journal_entry_description = Map.get(csv_item, "Journal Entry Description", "") journal_entry_details = Map.get(csv_item, "Journal Entry Details", "{}") audit_details = Map.get(csv_item, "Audit Details", "{}") - line_item_description = Map.get(csv_item, "Line Item Description", "") + description = Map.get(csv_item, "Line Item Description", "") posted_field = csv_posted |> String.trim() |> String.downcase() posted = if posted_field == "yes", do: true, else: false @@ -726,7 +726,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do journal_entry_details, audit_details, csv_posted, - line_item_description + description ), {:ok, transaction_date} <- parse_date(csv_item, "Transaction Date"), {:ok, general_ledger_posting_date} <- @@ -739,7 +739,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do journal_entry_number: journal_entry_number, transaction_reference_number: transaction_reference_number, journal_entry_description: updated_journal_entry_description, - line_item_description: line_item_description, + description: description, transaction_date: transaction_date, general_ledger_posting_date: general_ledger_posting_date, journal_entry_details: journal_entry_details, @@ -776,12 +776,12 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do journal_entry_details, audit_details, csv_posted, - line_item_description + description ) do is_binary(journal_entry_number) and journal_entry_number != "" and is_binary(transaction_reference_number) and is_binary(journal_entry_description) and is_binary(journal_entry_details) and is_binary(audit_details) and is_binary(csv_posted) and - is_binary(line_item_description) + is_binary(description) end defp generate_updated_journal_description( @@ -813,14 +813,14 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do journal_entry_number ) do account = Map.get(csv_item, "Account Name", "") - line_item_description = Map.get(csv_item, "Line Item Description", "") + description = Map.get(csv_item, "Line Item Description", "") debit = Map.get(csv_item, "Debit", "") credit = Map.get(csv_item, "Credit", "") case Enum.find(ok_params, fn param -> param.journal_entry_number == journal_entry_number end) do nil -> updated_t_accounts = - set_t_accounts(debit, credit, account, line_item_description, initial_params) + set_t_accounts(debit, credit, account, description, initial_params) params = Map.put(initial_params, :t_accounts, updated_t_accounts) @@ -828,7 +828,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do found_param -> updated_t_accounts = - set_t_accounts(debit, credit, account, line_item_description, found_param) + set_t_accounts(debit, credit, account, description, found_param) Enum.map(ok_params, fn %{journal_entry_number: je_number} when je_number == journal_entry_number -> @@ -840,13 +840,13 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do end end - defp set_t_accounts(debit, "" = _credit, account, line_item_description, params) do + defp set_t_accounts(debit, "" = _credit, account, description, params) do debit_amount = if debit == "", do: "0", else: Decimal.new(debit) t_accounts_debit_item = %{ account: account, amount: Decimal.new(debit_amount), - line_item_description: line_item_description + description: description } %{ @@ -855,13 +855,13 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do } end - defp set_t_accounts("" = _debit, credit, account, line_item_description, params) do + defp set_t_accounts("" = _debit, credit, account, description, params) do credit_amount = if credit == "", do: "0", else: Decimal.new(credit) t_accounts_credit_item = %{ account: account, amount: Decimal.new(credit_amount), - line_item_description: line_item_description + description: description } %{ diff --git a/lib/bookkeeping/boundary/chart_of_accounts/manager.ex b/lib/bookkeeping/boundary/chart_of_accounts/manager.ex index ed7945d..9e8147b 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/manager.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/manager.ex @@ -3,6 +3,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do alias Bookkeeping.Boundary.ChartOfAccounts.Worker + @spec start_link(any()) :: :ignore | {:error, any()} | {:ok, any()} def start_link(_) do case GenServer.start_link(__MODULE__, :ok, name: __MODULE__) do {:ok, pid} -> {:ok, pid} @@ -11,15 +12,19 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do end end + @spec init(any()) :: {:ok, atom() | :ets.tid()} + @impl true def init(_) do Process.flag(:trap_exit, true) setup_table() end + @impl true def handle_info({:EXIT, _from, _reason}, table) do {:noreply, table} end + @impl true def handle_info({:"ETS-TRANSFER", table, _pid, data}, _table) do worker = wait_for_worker() Process.link(worker) @@ -27,7 +32,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do {:noreply, table} end - def wait_for_worker() do + defp wait_for_worker() do case Process.whereis(Worker) do nil -> Process.sleep(1) @@ -38,7 +43,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do end end - def setup_table() do + defp setup_table() do case Process.whereis(Worker) do nil -> Process.sleep(1) diff --git a/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex b/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex index c501e8c..9a557ee 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/supervisor.ex @@ -15,9 +15,8 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Supervisor do Supervisor.start_link(__MODULE__, :ok, options) end - @impl true - @spec init(any()) :: {:ok, {sup_flags_t(), children_specs_t}} + @impl true def init(_init_arg) do children = [ {Worker, [name: Worker]}, diff --git a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex index 5c4ecf2..80fd31d 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -1,4 +1,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do + @moduledoc """ + This module is responsible for managing the chart of accounts. + The Chart of Accounts is a list of all the accounts used by an organization. + This module wraps the private Chart of Accounts ETS table and provides the interface to other modules. + The ETS table is a key-value store where the key is the account code, and the values are the account name and the account struct. + """ use GenServer alias Bookkeeping.Core.Account @@ -66,11 +72,6 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do {:noreply, table} end - @impl true - def handle_info(_msg, state) do - {:noreply, state} - end - @impl true def handle_call({:create, params}, _from, table) do {:reply, create(table, params), table} diff --git a/lib/bookkeeping/core/journal_entry.ex b/lib/bookkeeping/core/journal_entry.ex index 2e813bd..393cb73 100644 --- a/lib/bookkeeping/core/journal_entry.ex +++ b/lib/bookkeeping/core/journal_entry.ex @@ -55,8 +55,8 @@ defmodule Bookkeeping.Core.JournalEntry do ## Examples iex> JournalEntry.create(DateTime.utc_now(), DateTime.utc_now(), %{ - left: [%{account: asset_account, amount: Decimal.new(100), line_item_description: ""}], - right: [%{account: revenue_account, amount: Decimal.new(100), line_item_description: ""}] + left: [%{account: asset_account, amount: Decimal.new(100), description: ""}], + right: [%{account: revenue_account, amount: Decimal.new(100), description: ""}] }, "JE001001", "INV001001", "description", %{}, %{}) {:ok, %JournalEntry{...}} diff --git a/lib/bookkeeping/core/line_item.ex b/lib/bookkeeping/core/line_item.ex index 0a45d5c..4a3ec71 100644 --- a/lib/bookkeeping/core/line_item.ex +++ b/lib/bookkeeping/core/line_item.ex @@ -9,7 +9,7 @@ defmodule Bookkeeping.Core.LineItem do account: Account.t(), amount: Decimal.t(), entry_type: Types.entry(), - line_item_description: String.t() + description: String.t() } @type t_accounts :: %{ @@ -20,13 +20,13 @@ defmodule Bookkeeping.Core.LineItem do @type account_amount_pair :: %{ account: Account.t(), amount: Decimal.t(), - line_item_description: String.t() + description: String.t() } defstruct account: %Account{}, amount: 0, entry_type: nil, - line_item_description: "" + description: "" @doc """ Creates a list of line item structs. @@ -40,7 +40,7 @@ defmodule Bookkeeping.Core.LineItem do ## Examples - iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100), line_item_description: ""}], right: [%{account: asset_account, amount: Decimal.new(100), line_item_description: ""}]}) + iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100), description: ""}], right: [%{account: asset_account, amount: Decimal.new(100), description: ""}]}) {:ok, [%LineItem{...}, %LineItem{...}]} iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100)}], right: []}) @@ -89,7 +89,7 @@ defmodule Bookkeeping.Core.LineItem do Arguments: - account_amount_pair: The map with account and amount field. - atom_entry_type: The atom that represents the entry type of the line item. The atom must be either `:debit` or `:credit`. - - line_item_description (optional): The description of the line item. + - description (optional): The description of the line item. Returns `{:ok, %LineItem{}}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`, `{:error, :unbalanced_line_items}`, or `{:error, list(:invalid_amount | :invalid_account | :inactive_account)}`. @@ -107,14 +107,14 @@ defmodule Bookkeeping.Core.LineItem do with {:ok, %{account: account, amount: amount}} <- validate_account_and_amount(account_amount_pair), {:ok, entry_type} <- validate_entry_type(atom_entry_type) do - line_item_description = Map.get(account_amount_pair, :line_item_description, "") + description = Map.get(account_amount_pair, :description, "") {:ok, %__MODULE__{ account: account, amount: amount, entry_type: entry_type, - line_item_description: line_item_description + description: description }} else {:error, message} -> {:error, message} diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs index 990babd..36ac1b8 100644 --- a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs @@ -1,5 +1,4 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do - alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer alias Bookkeeping.Boundary.ChartOfAccounts.Supervisor, as: ChartOfAccountsSupervisor alias Bookkeeping.Boundary.ChartOfAccounts.Worker diff --git a/test/bookkeeping/boundary/accounting_journal_test.exs b/test/bookkeeping/boundary/accounting_journal_test.exs index 298e049..d660dce 100644 --- a/test/bookkeeping/boundary/accounting_journal_test.exs +++ b/test/bookkeeping/boundary/accounting_journal_test.exs @@ -2,7 +2,7 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do use ExUnit.Case, async: true alias Bookkeeping.Boundary.AccountingJournal.Backup, as: AccountingJournalBackup alias Bookkeeping.Boundary.AccountingJournal.Server, as: AccountingJournalServer - alias Bookkeeping.Boundary.ChartOfAccounts.Server, as: ChartOfAccountsServer + alias Bookkeeping.Boundary.ChartOfAccounts.Worker, as: ChartOfAccountsServer alias Bookkeeping.Core.Account setup do @@ -41,14 +41,14 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do %{ account: cash_account, amount: Decimal.new(100), - line_item_description: "cash from service revenue" + description: "cash from service revenue" } ], right: [ %{ account: revenue_account, amount: Decimal.new(100), - line_item_description: "service revenue" + description: "service revenue" } ] } @@ -414,15 +414,15 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do assert Enum.member?(journal_entry_descriptions, "JE_1007_INV") - line_item_descriptions = + descriptions = created_journals |> Enum.map(fn journal_entry -> journal_entry.line_items end) |> List.flatten() - |> Enum.map(fn line_item -> line_item.line_item_description end) + |> Enum.map(fn line_item -> line_item.description end) - assert Enum.member?(line_item_descriptions, "Bought a new property") - assert Enum.member?(line_item_descriptions, "Bought additional inventory from AAA Company") - assert Enum.member?(line_item_descriptions, "Remaining Payable amount") + assert Enum.member?(descriptions, "Bought a new property") + assert Enum.member?(descriptions, "Bought additional inventory from AAA Company") + assert Enum.member?(descriptions, "Remaining Payable amount") assert created_journals |> length() == 2 diff --git a/test/bookkeeping/core/journal_entry_test.exs b/test/bookkeeping/core/journal_entry_test.exs index bd6a604..ba0ac12 100644 --- a/test/bookkeeping/core/journal_entry_test.exs +++ b/test/bookkeeping/core/journal_entry_test.exs @@ -26,14 +26,14 @@ defmodule Bookkeeping.Core.JournalEntryTest do %{ account: asset_account, amount: Decimal.new(100), - line_item_description: "cash from service revenue" + description: "cash from service revenue" } ], right: [ %{ account: revenue_account, amount: Decimal.new(100), - line_item_description: "service revenue" + description: "service revenue" } ] } diff --git a/test/bookkeeping/core/line_item_test.exs b/test/bookkeeping/core/line_item_test.exs index c61a155..c07d930 100644 --- a/test/bookkeeping/core/line_item_test.exs +++ b/test/bookkeeping/core/line_item_test.exs @@ -20,14 +20,14 @@ defmodule Bookkeeping.Core.LineItemTest do %{ account: expense_account, amount: Decimal.new(100), - line_item_description: "rent expense" + description: "rent expense" } ], right: [ %{ account: asset_account, amount: Decimal.new(100), - line_item_description: "cash paid for rent" + description: "cash paid for rent" } ] }) @@ -88,7 +88,7 @@ defmodule Bookkeeping.Core.LineItemTest do assert line_item.account == asset_account assert line_item.amount == Decimal.new(100) assert line_item.entry_type == :debit - assert line_item.line_item_description == "" + assert line_item.description == "" end test "disallow line item with invalid fields" do From aac226f33721a0097a23bd08a3f0d7c6d0a5a80c Mon Sep 17 00:00:00 2001 From: jeryldev Date: Wed, 20 Dec 2023 23:31:08 +0800 Subject: [PATCH 19/32] update the chart of accounts worker and its test --- .../boundary/chart_of_accounts/worker.ex | 80 ++++++++++++++----- .../boundary/chart_of_accounts_test.exs | 22 +++++ 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex index 80fd31d..e62cf5d 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -19,9 +19,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do end @spec create(Account.create_params()) :: - {:ok, Account.t()} | {:error, :already_exists | :invalid_field | :invalid_params} + {:ok, Account.t()} + | {:error, :invalid_table | :already_exists | :invalid_field | :invalid_params} def create(params) do - GenServer.call(__MODULE__, {:create, params}) + maybe_handle_call({:create, params}) end @spec import_file(String.t()) :: @@ -30,7 +31,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do accounts: list(Account.t()), errors: list(%{ - reason: :invalid_params | :invalid_field | :already_exists, + reason: :invalid_table | :invalid_params | :invalid_field | :already_exists, params: Account.create_params() }) }} @@ -40,20 +41,26 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do end @spec update(Account.t(), Account.update_params()) :: - {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} + {:ok, Account.t()} + | {:error, :invalid_table | :invalid_account | :invalid_field | :invalid_params} def update(account, params) do - GenServer.call(__MODULE__, {:update, account, params}) + maybe_handle_call({:update, account, params}) + end + + @spec all_accounts() :: {:ok, list(Account.t())} | {:error, :invalid_table} + def all_accounts do + maybe_handle_call(:all_accounts) end @spec search_code(Account.account_code()) :: - {:ok, Account.t()} | {:error, :not_found | :invalid_code} + {:ok, Account.t()} | {:error, :invalid_table | :not_found | :invalid_code} def search_code(code) do - GenServer.call(__MODULE__, {:search_code, code}) + maybe_handle_call({:search_code, code}) end - @spec search_name(String.t()) :: {:ok, Account.t()} | {:error, :not_found} + @spec search_name(String.t()) :: {:ok, Account.t()} | {:error, :invalid_table | :not_found} def search_name(name) do - GenServer.call(__MODULE__, {:search_name, name}) + maybe_handle_call({:search_name, name}) end @spec die() :: :ok @@ -74,29 +81,54 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do @impl true def handle_call({:create, params}, _from, table) do - {:reply, create(table, params), table} + account = maybe_handle_function(&create/2, table, [params]) + {:reply, account, table} end @impl true def handle_call({:update, account, params}, _from, table) do - {:reply, update(table, account, params), table} + account = maybe_handle_function(&update/3, table, [account, params]) + {:reply, account, table} + end + + @impl true + def handle_call(:all_accounts, _from, table) do + accounts = maybe_handle_function(&all_accounts/1, table) + {:reply, accounts, table} end @impl true def handle_call({:search_code, prefix}, _from, table) do - {:reply, prefix_search_code(table, prefix), table} + accounts = maybe_handle_function(&prefix_search_code/2, table, [prefix]) + {:reply, accounts, table} end @impl true def handle_call({:search_name, prefix}, _from, table) do - {:reply, prefix_search_name(table, prefix), table} + accounts = maybe_handle_function(&prefix_search_name/2, table, [prefix]) + {:reply, accounts, table} end @impl true def handle_cast(:die, table) do + table = maybe_handle_function(fn x -> x end, table) {:stop, table, :killed} end + defp maybe_handle_function(fun, table, params \\ []) do + with {:ok, table} <- check_table(table) do + case params do + [] -> fun.(table) + [params] -> fun.(table, params) + [params1, params2] -> fun.(table, params1, params2) + end + end + end + + defp check_table(table) do + if Enum.member?(:ets.all(), table), do: {:ok, table}, else: {:error, :invalid_table} + end + defp create(table, params) do with {:ok, account} <- Account.create(params), {:error, :not_found} <- check_similar_account(table, account) do @@ -112,21 +144,25 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do end end + defp all_accounts(table) do + {:ok, :ets.select(table, [{{:_, :_, :"$1"}, [], [:"$1"]}])} + end + defp check_similar_account(table, account) do - with {:ok, _account} <- search_code(table, account.code), - {:ok, _account} <- search_name(table, account.name) do + with {:ok, _account} <- match_code(table, account.code), + {:ok, _account} <- match_name(table, account.name) do {:error, :already_exists} end end - defp search_code(table, code) do + defp match_code(table, code) do case :ets.lookup(table, code) do [{_, _, account}] -> {:ok, account} - [] -> {:error, :not_found} + _ -> {:error, :not_found} end end - defp search_name(table, name) do + defp match_name(table, name) do result = :ets.select(table, [{{:_, name, :"$1"}, [], [:"$1"]}]) |> List.first() if is_nil(result), do: {:error, :not_found}, else: {:ok, result} end @@ -233,4 +269,12 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do end defp bulk_create(error), do: error + + defp maybe_handle_call(handle_call) do + try do + GenServer.call(__MODULE__, handle_call) + catch + _message, _reason -> {:error, :invalid_table} + end + end end diff --git a/test/bookkeeping/boundary/chart_of_accounts_test.exs b/test/bookkeeping/boundary/chart_of_accounts_test.exs index 9981472..1f49a93 100644 --- a/test/bookkeeping/boundary/chart_of_accounts_test.exs +++ b/test/bookkeeping/boundary/chart_of_accounts_test.exs @@ -205,6 +205,14 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do end end + describe "all_accounts/0" do + test "returns all accounts", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + assert {:ok, accounts} = ChartOfAccounts.all_accounts() + assert account in accounts + end + end + describe "search_name/1" do test "with complete name", %{params: params} do params = update_params(params) @@ -246,6 +254,20 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) assert account in accounts end + + test "returns error if the table is not yet available yet", %{params: params} do + ChartOfAccounts.die() + assert {:error, :invalid_table} = ChartOfAccounts.all_accounts() + assert {:error, :invalid_table} = ChartOfAccounts.create(params) + + Process.sleep(300) + params = update_params(params) + assert {:ok, account} = ChartOfAccounts.create(params) + assert is_struct(account) + + assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) + assert account in accounts + end end defp update_params(params) do From 79f76f60b146c3f4ed25054f4d11172e12829ad6 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Wed, 20 Dec 2023 23:31:52 +0800 Subject: [PATCH 20/32] fix account and its test --- lib/bookkeeping/core/account.ex | 19 + test/bookkeeping/core/account_test.exs | 520 +++++++++++++++---------- 2 files changed, 323 insertions(+), 216 deletions(-) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 23b5386..4f5ea59 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -75,6 +75,23 @@ defmodule Bookkeeping.Core.Account do category: nil, contra: false + @doc """ + Returns the classification struct. + + Arguments: + - classification: The name of the classification.The account classification must be one of the following: `asset`, `liability`, `equity`, `revenue`, `expense`, `gain`, `loss`, `contra_asset`, `contra_liability`, `contra_equity`, `contra_revenue`, `contra_expense`, `contra_gain`, `contra_loss`. + + Returns `%Classification{}` if the classification is valid. Otherwise, returns `nil`. + + ## Examples + + iex> Classification.classify("asset") + %Classification{...} + + iex> Classification.classify("invalid") + nil + """ + @spec classify(String.t()) :: __MODULE__.t() def classify("asset") do %Classification{ name: "Asset", @@ -200,6 +217,8 @@ defmodule Bookkeeping.Core.Account do contra: true } end + + def classify(_), do: nil end @doc """ diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index 33f455e..023e421 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -7,6 +7,85 @@ defmodule Bookkeeping.Core.AccountTest do {:ok, details: details} end + describe "Classification classify/1" do + test "with valid params" do + asset = Account.Classification.classify("asset") + assert is_struct(asset) + assert asset.name == "Asset" + assert asset.normal_balance == :debit + + liability = Account.Classification.classify("liability") + assert is_struct(liability) + assert liability.name == "Liability" + assert liability.normal_balance == :credit + + equity = Account.Classification.classify("equity") + assert is_struct(equity) + assert equity.name == "Equity" + assert equity.normal_balance == :credit + + revenue = Account.Classification.classify("revenue") + assert is_struct(revenue) + assert revenue.name == "Revenue" + assert revenue.normal_balance == :credit + + expense = Account.Classification.classify("expense") + assert is_struct(expense) + assert expense.name == "Expense" + assert expense.normal_balance == :debit + + gain = Account.Classification.classify("gain") + assert is_struct(gain) + assert gain.name == "Gain" + assert gain.normal_balance == :credit + + loss = Account.Classification.classify("loss") + assert is_struct(loss) + assert loss.name == "Loss" + assert loss.normal_balance == :debit + + contra_asset = Account.Classification.classify("contra_asset") + assert is_struct(contra_asset) + assert contra_asset.name == "Contra Asset" + assert contra_asset.normal_balance == :credit + + contra_liability = Account.Classification.classify("contra_liability") + assert is_struct(contra_liability) + assert contra_liability.name == "Contra Liability" + assert contra_liability.normal_balance == :debit + + contra_equity = Account.Classification.classify("contra_equity") + assert is_struct(contra_equity) + assert contra_equity.name == "Contra Equity" + assert contra_equity.normal_balance == :debit + + contra_revenue = Account.Classification.classify("contra_revenue") + assert is_struct(contra_revenue) + assert contra_revenue.name == "Contra Revenue" + assert contra_revenue.normal_balance == :debit + + contra_expense = Account.Classification.classify("contra_expense") + assert is_struct(contra_expense) + assert contra_expense.name == "Contra Expense" + assert contra_expense.normal_balance == :credit + + contra_gain = Account.Classification.classify("contra_gain") + assert is_struct(contra_gain) + assert contra_gain.name == "Contra Gain" + assert contra_gain.normal_balance == :debit + + contra_loss = Account.Classification.classify("contra_loss") + assert is_struct(contra_loss) + assert contra_loss.name == "Contra Loss" + assert contra_loss.normal_balance == :credit + end + + test "with invalid params" do + assert nil == Account.Classification.classify(nil) + assert nil == Account.Classification.classify("apple") + end + end + describe "create/1" do test "with valid params", %{details: details} do assert {:ok, account} = @@ -28,220 +107,220 @@ defmodule Bookkeeping.Core.AccountTest do assert is_list(account.audit_logs) assert is_struct(account.classification, Bookkeeping.Core.Account.Classification) - assert {:ok, _liability} = - Account.create(%{ - code: "20_000", - name: "liability", - classification: "liability", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _equity} = - Account.create(%{ - code: "30_000", - name: "equity", - classification: "equity", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _revenue} = - Account.create(%{ - code: "40_000", - name: "revenue", - classification: "revenue", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _expense} = - Account.create(%{ - code: "50_000", - name: "expense", - classification: "expense", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _gain} = - Account.create(%{ - code: "50_000", - name: "gain", - classification: "gain", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _loss} = - Account.create(%{ - code: "50_000", - name: "loss", - classification: "loss", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_asset} = - Account.create(%{ - code: "60_000", - name: "contra_asset", - classification: "contra_asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_liability} = - Account.create(%{ - code: "70_000", - name: "contra_liability", - classification: "contra_liability", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_equity} = - Account.create(%{ - code: "80_000", - name: "contra_equity", - classification: "contra_equity", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_revenue} = - Account.create(%{ - code: "90_000", - name: "contra_revenue", - classification: "contra_revenue", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_expense} = - Account.create(%{ - code: "100_000", - name: "contra_expense", - classification: "contra_expense", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_gain} = - Account.create(%{ - code: "100_000", - name: "contra_gain", - classification: "contra_gain", - description: "description", - audit_details: details, - active: true - }) - - assert {:ok, _contra_loss} = - Account.create(%{ - code: "100_000", - name: "contra_loss", - classification: "contra_loss", - description: "description", - audit_details: details, - active: true - }) - end - - test "create/1 with invalid params", %{details: details} do - assert {:error, :invalid_params} = Account.create(%{}) - assert {:error, :invalid_params} = Account.create(nil) - assert {:error, :invalid_params} = Account.create("apple") - - assert {:error, :invalid_params} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description" - }) - - assert {:error, :invalid_params} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details - }) - end - - test "with invalid field", %{details: details} do - assert {:error, :invalid_field} = - Account.create(%{ - code: 10_000, - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:error, :invalid_field} = - Account.create(%{ - code: "10_000", - name: nil, - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - - assert {:error, :invalid_field} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: nil, - description: "description", - audit_details: details, - active: true - }) - - assert {:error, :invalid_field} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: nil, - audit_details: details, - active: true - }) - - assert {:error, :invalid_field} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: nil, - active: true - }) - - assert {:error, :invalid_field} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: nil - }) + # assert {:ok, _liability} = + # Account.create(%{ + # code: "20_000", + # name: "liability", + # classification: "liability", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _equity} = + # Account.create(%{ + # code: "30_000", + # name: "equity", + # classification: "equity", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _revenue} = + # Account.create(%{ + # code: "40_000", + # name: "revenue", + # classification: "revenue", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _expense} = + # Account.create(%{ + # code: "50_000", + # name: "expense", + # classification: "expense", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _gain} = + # Account.create(%{ + # code: "50_000", + # name: "gain", + # classification: "gain", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _loss} = + # Account.create(%{ + # code: "50_000", + # name: "loss", + # classification: "loss", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _contra_asset} = + # Account.create(%{ + # code: "60_000", + # name: "contra_asset", + # classification: "contra_asset", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _contra_liability} = + # Account.create(%{ + # code: "70_000", + # name: "contra_liability", + # classification: "contra_liability", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _contra_equity} = + # Account.create(%{ + # code: "80_000", + # name: "contra_equity", + # classification: "contra_equity", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _contra_revenue} = + # Account.create(%{ + # code: "90_000", + # name: "contra_revenue", + # classification: "contra_revenue", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _contra_expense} = + # Account.create(%{ + # code: "100_000", + # name: "contra_expense", + # classification: "contra_expense", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _contra_gain} = + # Account.create(%{ + # code: "100_000", + # name: "contra_gain", + # classification: "contra_gain", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:ok, _contra_loss} = + # Account.create(%{ + # code: "100_000", + # name: "contra_loss", + # classification: "contra_loss", + # description: "description", + # audit_details: details, + # active: true + # }) + # end + + # test "create/1 with invalid params", %{details: details} do + # assert {:error, :invalid_params} = Account.create(%{}) + # assert {:error, :invalid_params} = Account.create(nil) + # assert {:error, :invalid_params} = Account.create("apple") + + # assert {:error, :invalid_params} = + # Account.create(%{ + # code: "10_000", + # name: "cash", + # classification: "asset", + # description: "description" + # }) + + # assert {:error, :invalid_params} = + # Account.create(%{ + # code: "10_000", + # name: "cash", + # classification: "asset", + # description: "description", + # audit_details: details + # }) + # end + + # test "with invalid field", %{details: details} do + # assert {:error, :invalid_field} = + # Account.create(%{ + # code: 10_000, + # name: "cash", + # classification: "asset", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:error, :invalid_field} = + # Account.create(%{ + # code: "10_000", + # name: nil, + # classification: "asset", + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:error, :invalid_field} = + # Account.create(%{ + # code: "10_000", + # name: "cash", + # classification: nil, + # description: "description", + # audit_details: details, + # active: true + # }) + + # assert {:error, :invalid_field} = + # Account.create(%{ + # code: "10_000", + # name: "cash", + # classification: "asset", + # description: nil, + # audit_details: details, + # active: true + # }) + + # assert {:error, :invalid_field} = + # Account.create(%{ + # code: "10_000", + # name: "cash", + # classification: "asset", + # description: "description", + # audit_details: nil, + # active: true + # }) + + # assert {:error, :invalid_field} = + # Account.create(%{ + # code: "10_000", + # name: "cash", + # classification: "asset", + # description: "description", + # audit_details: details, + # active: nil + # }) end end @@ -273,8 +352,17 @@ defmodule Bookkeeping.Core.AccountTest do end test "with invalid account" do - assert {:error, :invalid_account} = Account.update(%Account{}, %{}) - assert {:error, :invalid_account} = Account.update(nil, %{}) + params = %{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: %{}, + active: true + } + + assert {:error, :invalid_account} = Account.update(%Account{}, params) + assert {:error, :invalid_account} = Account.update(nil, params) end test "with invalid field" do From fab6767c415e12ff4b6b47ec69ccc87eab698458 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 21 Dec 2023 13:24:13 +0800 Subject: [PATCH 21/32] add tests to chart of accounts --- .../boundary/chart_of_accounts/worker.ex | 33 ++++++++++++------- .../boundary/chart_of_accounts_test.exs | 31 ++++++++++++----- .../data/valid_chart_of_accounts_2.csv | 10 ++++++ 3 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 test/bookkeeping/data/valid_chart_of_accounts_2.csv diff --git a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex index e62cf5d..5e6a3da 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -20,7 +20,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do @spec create(Account.create_params()) :: {:ok, Account.t()} - | {:error, :invalid_table | :already_exists | :invalid_field | :invalid_params} + | {:error, :already_exists | :invalid_field | :invalid_params} def create(params) do maybe_handle_call({:create, params}) end @@ -31,7 +31,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do accounts: list(Account.t()), errors: list(%{ - reason: :invalid_table | :invalid_params | :invalid_field | :already_exists, + reason: :invalid_params | :invalid_field | :already_exists, params: Account.create_params() }) }} @@ -42,23 +42,23 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do @spec update(Account.t(), Account.update_params()) :: {:ok, Account.t()} - | {:error, :invalid_table | :invalid_account | :invalid_field | :invalid_params} + | {:error, :invalid_account | :invalid_field | :invalid_params} def update(account, params) do maybe_handle_call({:update, account, params}) end - @spec all_accounts() :: {:ok, list(Account.t())} | {:error, :invalid_table} + @spec all_accounts() :: {:ok, list(Account.t())} def all_accounts do maybe_handle_call(:all_accounts) end @spec search_code(Account.account_code()) :: - {:ok, Account.t()} | {:error, :invalid_table | :not_found | :invalid_code} + {:ok, Account.t()} | {:error, :not_found | :invalid_code} def search_code(code) do maybe_handle_call({:search_code, code}) end - @spec search_name(String.t()) :: {:ok, Account.t()} | {:error, :invalid_table | :not_found} + @spec search_name(String.t()) :: {:ok, Account.t()} | {:error, :not_found} def search_name(name) do maybe_handle_call({:search_name, name}) end @@ -270,11 +270,22 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do defp bulk_create(error), do: error - defp maybe_handle_call(handle_call) do - try do - GenServer.call(__MODULE__, handle_call) - catch - _message, _reason -> {:error, :invalid_table} + defp maybe_handle_call(handle_call, backoff \\ 100) do + result = + try do + GenServer.call(__MODULE__, handle_call) + catch + _message, _reason -> {:error, :invalid_table} + end + + case result do + {:error, :invalid_table} -> + Process.sleep(backoff) + backoff = min(3000, round(backoff * 2)) + maybe_handle_call(handle_call, backoff) + + result -> + result end end end diff --git a/test/bookkeeping/boundary/chart_of_accounts_test.exs b/test/bookkeeping/boundary/chart_of_accounts_test.exs index 1f49a93..b486c5d 100644 --- a/test/bookkeeping/boundary/chart_of_accounts_test.exs +++ b/test/bookkeeping/boundary/chart_of_accounts_test.exs @@ -247,26 +247,39 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do ChartOfAccounts.die() - # We need to add another test and feature that will allow ChartOfAccounts to - # delay the process calls until the worker is ready again. - Process.sleep(300) - assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) assert account in accounts end - test "returns error if the table is not yet available yet", %{params: params} do + test "repeats the call until the proper response is returned if the table is not yet available and then function was immediately called", + %{params: params} do ChartOfAccounts.die() - assert {:error, :invalid_table} = ChartOfAccounts.all_accounts() - assert {:error, :invalid_table} = ChartOfAccounts.create(params) - - Process.sleep(300) params = update_params(params) assert {:ok, account} = ChartOfAccounts.create(params) assert is_struct(account) + ChartOfAccounts.die() assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) assert account in accounts + assert {:ok, accounts} = ChartOfAccounts.search_name(account.name) + assert account in accounts + + ChartOfAccounts.die() + assert {:ok, updated_account} = ChartOfAccounts.update(account, %{name: "Cash updated"}) + assert updated_account.code == account.code + assert updated_account.name == "Cash updated" + + ChartOfAccounts.die() + + assert %{accounts: accounts, errors: []} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/valid_chart_of_accounts_2.csv" + ) + + assert length(accounts) == 9 + + ChartOfAccounts.die() + assert {:ok, _accounts} = ChartOfAccounts.all_accounts() end end diff --git a/test/bookkeeping/data/valid_chart_of_accounts_2.csv b/test/bookkeeping/data/valid_chart_of_accounts_2.csv new file mode 100644 index 0000000..6875a62 --- /dev/null +++ b/test/bookkeeping/data/valid_chart_of_accounts_2.csv @@ -0,0 +1,10 @@ +Account Code,Account Name,Account Type,Account Description,Audit Details +1013,Cash,asset,Cash,"{""approved_by"": ""example@example.com""}" +100000101,Accounts Receivable Bulk Test,asset,Accounts Receivable,{} +1034,Inventory,asset,Inventory,"{""approved_by"": ""example@example.com""}" +1045,"Property, Plant, and Equipment",asset,"Property, Plant, and Equipment","{""approved_by"": ""example@example.com""}" +4029,Service Revenue,revenue,Service Revenue,"{""approved_by"": ""example@example.com""}" +2027,Short-term Debt,liability,Short-term Debt,"{""approved_by"": ""example@example.com""}" +2016,Accounts Payable,liability,Accounts Payable,"{""approved_by"": ""example@example.com""}" +100000002,Cash Bulk Test,asset,Cash,"{""approved_by"": ""example@example.com""}" +2038,Long-term Debt,liability,Long-term Debt,"{""approved_by"": ""example@example.com""}" From 8fad1efb1588beacd53bb5bb8d40b1c2ca2c21b4 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 21 Dec 2023 14:46:15 +0800 Subject: [PATCH 22/32] rename the account type to classification --- .../boundary/chart_of_accounts/worker.ex | 40 +++++++------------ .../data/sample_chart_of_accounts.csv | 2 +- .../boundary/chart_of_accounts_test.exs | 16 ++++---- .../data/duplicate_chart_of_accounts.csv | 2 +- .../data/empty_chart_of_accounts.csv | 2 +- .../data/empty_chart_of_accounts_2.csv | 2 +- .../data/invalid_chart_of_accounts.csv | 2 +- .../partially_valid_chart_of_accounts.csv | 2 +- .../data/valid_bookkeeping_accounts.csv | 2 +- .../data/valid_chart_of_accounts.csv | 2 +- .../data/valid_chart_of_accounts_2.csv | 2 +- 11 files changed, 31 insertions(+), 43 deletions(-) diff --git a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex index 5e6a3da..aecb206 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -21,9 +21,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do @spec create(Account.create_params()) :: {:ok, Account.t()} | {:error, :already_exists | :invalid_field | :invalid_params} - def create(params) do - maybe_handle_call({:create, params}) - end + def create(params), do: maybe_handle_call({:create, params}) @spec import_file(String.t()) :: {:ok, @@ -37,47 +35,37 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do }} | {:error, :invalid_file} def import_file(file_path) do - file_path |> check_csv() |> read_csv() |> bulk_generate_params() |> bulk_create() + file_path + |> check_csv() + |> read_csv() + |> bulk_generate_params() + |> bulk_create() end @spec update(Account.t(), Account.update_params()) :: {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} - def update(account, params) do - maybe_handle_call({:update, account, params}) - end + def update(account, params), do: maybe_handle_call({:update, account, params}) @spec all_accounts() :: {:ok, list(Account.t())} - def all_accounts do - maybe_handle_call(:all_accounts) - end + def all_accounts, do: maybe_handle_call(:all_accounts) @spec search_code(Account.account_code()) :: {:ok, Account.t()} | {:error, :not_found | :invalid_code} - def search_code(code) do - maybe_handle_call({:search_code, code}) - end + def search_code(code), do: maybe_handle_call({:search_code, code}) @spec search_name(String.t()) :: {:ok, Account.t()} | {:error, :not_found} - def search_name(name) do - maybe_handle_call({:search_name, name}) - end + def search_name(name), do: maybe_handle_call({:search_name, name}) @spec die() :: :ok - def die do - GenServer.cast(__MODULE__, :die) - end + def die, do: GenServer.cast(__MODULE__, :die) @spec init(any()) :: {:ok, nil} @impl true - def init(_) do - {:ok, nil} - end + def init(_), do: {:ok, nil} @impl true - def handle_info({:"ETS-TRANSFER", table, _pid, _data}, _table) do - {:noreply, table} - end + def handle_info({:"ETS-TRANSFER", table, _pid, _data}, _table), do: {:noreply, table} @impl true def handle_call({:create, params}, _from, table) do @@ -235,7 +223,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do Enum.reduce(csv, [], fn csv_item, acc -> code = Map.get(csv_item, "Account Code") name = Map.get(csv_item, "Account Name") - classification = Map.get(csv_item, "Account Type") + classification = Map.get(csv_item, "Classification") description = Map.get(csv_item, "Account Description", "") audit_details = diff --git a/lib/bookkeeping/data/sample_chart_of_accounts.csv b/lib/bookkeeping/data/sample_chart_of_accounts.csv index 76803a0..8804900 100644 --- a/lib/bookkeeping/data/sample_chart_of_accounts.csv +++ b/lib/bookkeeping/data/sample_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details 101,Cash,asset,Cash,"{""approved_by"": ""example@example.com""}" 102,Accounts Receivable,asset,Accounts Receivable,"{""approved_by"": ""example@example.com""}" 103,Inventory,asset,Inventory,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/boundary/chart_of_accounts_test.exs b/test/bookkeeping/boundary/chart_of_accounts_test.exs index b486c5d..ba29e80 100644 --- a/test/bookkeeping/boundary/chart_of_accounts_test.exs +++ b/test/bookkeeping/boundary/chart_of_accounts_test.exs @@ -178,6 +178,14 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do end end + describe "all_accounts/0" do + test "returns all accounts", %{params: params} do + {:ok, account} = ChartOfAccounts.create(params) + assert {:ok, accounts} = ChartOfAccounts.all_accounts() + assert account in accounts + end + end + describe "search_code/1" do test "with complete code", %{params: params} do params = update_params(params) @@ -205,14 +213,6 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do end end - describe "all_accounts/0" do - test "returns all accounts", %{params: params} do - {:ok, account} = ChartOfAccounts.create(params) - assert {:ok, accounts} = ChartOfAccounts.all_accounts() - assert account in accounts - end - end - describe "search_name/1" do test "with complete name", %{params: params} do params = update_params(params) diff --git a/test/bookkeeping/data/duplicate_chart_of_accounts.csv b/test/bookkeeping/data/duplicate_chart_of_accounts.csv index ab18b11..a3890ab 100644 --- a/test/bookkeeping/data/duplicate_chart_of_accounts.csv +++ b/test/bookkeeping/data/duplicate_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details 100000101,Accounts Receivable Bulk Duplicate Test,asset,Accounts Receivable,{} 100000001,Cash Bulk Duplicate Test,asset,Cash,"{""approved_by"": ""example@example.com""}" 100000001,Cash Bulk Duplicate Test,asset,Cash,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/data/empty_chart_of_accounts.csv b/test/bookkeeping/data/empty_chart_of_accounts.csv index b8157e6..732ee4b 100644 --- a/test/bookkeeping/data/empty_chart_of_accounts.csv +++ b/test/bookkeeping/data/empty_chart_of_accounts.csv @@ -1 +1 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details diff --git a/test/bookkeeping/data/empty_chart_of_accounts_2.csv b/test/bookkeeping/data/empty_chart_of_accounts_2.csv index 0015bfa..053394d 100644 --- a/test/bookkeeping/data/empty_chart_of_accounts_2.csv +++ b/test/bookkeeping/data/empty_chart_of_accounts_2.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details diff --git a/test/bookkeeping/data/invalid_chart_of_accounts.csv b/test/bookkeeping/data/invalid_chart_of_accounts.csv index cc19e73..8e48a5d 100644 --- a/test/bookkeeping/data/invalid_chart_of_accounts.csv +++ b/test/bookkeeping/data/invalid_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details ,,asset,Cash,"{""approved_by"": ""example@example.com""}" ,, 1000000020,Accounts Receivable Bulk Test,asset,Accounts Receivable,100 diff --git a/test/bookkeeping/data/partially_valid_chart_of_accounts.csv b/test/bookkeeping/data/partially_valid_chart_of_accounts.csv index 97e61fb..3af8c68 100644 --- a/test/bookkeeping/data/partially_valid_chart_of_accounts.csv +++ b/test/bookkeeping/data/partially_valid_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details 1000001012,Accounts Receivable Bulk Test 2,asset,Accounts Receivable,{} 1000001012,Accounts Receivable Bulk Test 2,asset,Accounts Receivable,{} 1000000012,Cash Bulk Test 2,asset,Cash,"test" diff --git a/test/bookkeeping/data/valid_bookkeeping_accounts.csv b/test/bookkeeping/data/valid_bookkeeping_accounts.csv index 0d74229..c2fe84a 100644 --- a/test/bookkeeping/data/valid_bookkeeping_accounts.csv +++ b/test/bookkeeping/data/valid_bookkeeping_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details 1001_bookkeeping_test,Accounts Receivable Bookkeeping Test,asset,Accounts Receivable,{} 1002_bookkeeping_test,Cash Bookkeeping Test,asset,Cash,"{""approved_by"": ""example@example.com""}" 101_bookkeeping_test,Cash Bookkeeping Test,asset,Cash,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/data/valid_chart_of_accounts.csv b/test/bookkeeping/data/valid_chart_of_accounts.csv index 26420e8..47b2932 100644 --- a/test/bookkeeping/data/valid_chart_of_accounts.csv +++ b/test/bookkeeping/data/valid_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details 10000010,Accounts Receivable Bulk Test,asset,Accounts Receivable,{} 10000000,Cash Bulk Test,asset,Cash,"{""approved_by"": ""example@example.com""}" 101,Cash,asset,Cash,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/data/valid_chart_of_accounts_2.csv b/test/bookkeeping/data/valid_chart_of_accounts_2.csv index 6875a62..f739573 100644 --- a/test/bookkeeping/data/valid_chart_of_accounts_2.csv +++ b/test/bookkeeping/data/valid_chart_of_accounts_2.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details +Account Code,Account Name,Classification,Account Description,Audit Details 1013,Cash,asset,Cash,"{""approved_by"": ""example@example.com""}" 100000101,Accounts Receivable Bulk Test,asset,Accounts Receivable,{} 1034,Inventory,asset,Inventory,"{""approved_by"": ""example@example.com""}" From c968a0010acd515f37cb8bdbfb7ca5ba37310328 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 21 Dec 2023 18:03:46 +0800 Subject: [PATCH 23/32] update the bookkeeping and its tests --- lib/bookkeeping.ex | 530 ++++++++-------- .../boundary/chart_of_accounts/worker.ex | 5 +- test/bookkeeping_test.exs | 596 +++++++----------- 3 files changed, 510 insertions(+), 621 deletions(-) diff --git a/lib/bookkeeping.ex b/lib/bookkeeping.ex index 29f0eef..5bb38d4 100644 --- a/lib/bookkeeping.ex +++ b/lib/bookkeeping.ex @@ -19,82 +19,98 @@ defmodule Bookkeeping do ########################################################## @doc """ - Creates a new account. + Creates an account. Arguments: - - code: The unique code of the account. - - name: The unique name of the account. - - classification: The classification of the account. The account classification must be one of the following: `"asset"`, `"liability"`, `"equity"`, `"revenue"`, `"expense"`, `"gain"`, `"loss"`, `"contra_asset"`, `"contra_liability"`, `"contra_equity"`, `"contra_revenue"`, `"contra_expense"`, `"contra_gain"`, `"contra_loss"`. - - description: The description of the account. - - audit_details: The audit details of the account. + - params: The map of account attributes. The map must have the following keys: + - code: The unique code of the account. + - name: The unique name of the account. + - classification: The classification of the account. The account classification must be one of the following: `asset`, `liability`, `equity`, `revenue`, `expense`, `gain`, `loss`, `contra_asset`, `contra_liability`, `contra_equity`, `contra_revenue`, `contra_expense`, `contra_gain`, `contra_loss`. + - description: The description of the account. + - audit_details: The details of the audit log. + - active: The status of the account. The account status must be one of the following: `true` or `false`. - Returns `{:ok, account}` if the account is valid, otherwise `{:error, :invalid_account}`. + Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_params}` or `{:error, :invalid_field}`. ## Examples - iex> Bookkeeping.create_account(server, "1000", "Cash", "asset", "", %{}) - {:ok, %Bookkeeping.Core.Account{...}} + iex> Bookkeeping.create_account(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: %{}, active: true}) + {:ok, %Account{...}} - iex> Bookkeeping.create_account(server, "invalid", "invalid", nil, false, %{}) - {:error, :invalid_account} + iex> Bookkeeping.create_account([])" + {:error, :invalid_params} + + iex> Bookkeeping.create_account(%{code: "invalid", name: "invalid", classification: "invalid", description: nil, audit_details: false, active: %{}}) + {:error, :invalid_field} """ - @spec create_account(String.t(), String.t(), String.t(), String.t(), map()) :: - {:ok, Account.t()} | {:error, :invalid_account} | {:error, :account_already_exists} - defdelegate create_account(code, name, classification, description, audit_details), - to: ChartOfAccounts + @spec create_account(map()) :: {:ok, Account.t()} | {:error, :invalid_params | :invalid_field} + def create_account(params), do: ChartOfAccounts.create(params) @doc """ - Imports default accounts from a CSV file. - The headers of the CSV file must be `Account Code`, `Account Name`, `Account Type`, `Description`, and `Audit Details`. + Imports accounts from a CSV file. + + The header of the CSV file must be `Account Code`, `Account Name`, `Classification`, `Account Description`, and `Audit Details`. Arguments: - - path: The path of the CSV file. The path to the default accounts is "../data/sample_chart_of_accounts.csv". + - path: The path of the CSV file. - Returns `{:ok, %{ok: list(map()), error: list(map())}}` if the accounts are imported successfully. If all items are encountered an error, return `{:error, %{ok: list(map()), error: list(map())}}`. + Returns `{:ok, %{accounts: [...], errors: [...]}}` if the accounts are imported successfully and if it has errors. Otherwise, returns `{:error, :invalid_file}`. ## Examples - iex> Bookkeeping.import_accounts(server, "../data/sample_chart_of_accounts.csv") + iex> Bookkeeping.import_accounts("../../data/sample_chart_of_accounts.csv") {:ok, %{ - ok: [%{account_code: "1000", account_name: "Cash"}, ...], - error: [] + accounts: [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...], + errors: [] }} - iex> Bookkeeping.import_accounts(server, "../data/invalid_chart_of_accounts.csv") - {:error, - %{ - ok: [], - error: [ - %{account_code: "1001", account_name: "Cash", error: :account_already_exists}, - %{account_code: "1002", account_name: "Cash", error: :invalid_account}, - ... - ] - }} + iex> Bookkeeping.import_accounts("../../data/invalid_file.csv") + {:error, :invalid_file} """ @spec import_accounts(String.t()) :: - {:ok, %{ok: list(Account.t()), error: list(map())}} - | {:error, %{ok: list(Account.t()), error: list(map())}} - | {:error, %{message: :invalid_csv, errors: list(map())}} + {:ok, + %{ + accounts: list(Account.t()), + errors: + list(%{ + reason: :invalid_params | :invalid_field | :already_exists, + params: Account.create_params() + }) + }} | {:error, :invalid_file} - defdelegate import_accounts(file_path), to: ChartOfAccounts + def import_accounts(file_path), do: ChartOfAccounts.import_file(file_path) @doc """ Updates an account. Arguments: - account: The account to be updated. - - attrs: The attributes to be updated. The editable attributes are `name`, `description`, `active`, and `audit_details`. + - params: The map of account attributes. The map must have the following keys: + - name: The unique name of the account. + - description: The description of the account. + - audit_details: The details of the audit log. + - active: The status of the account. The account status must be one of the following: `true` or `false`. - Returns `{:ok, account}` if the account is valid, otherwise `{:error, :invalid_account}`. + Returns `{:ok, account}` if the account is valid, otherwise `{:error, :invalid_account}`, `{:error, :invalid_field}`, or `{:error, :invalid_params}`. ## Examples - iex> Bookkeeping.update_account(server, account, %{name: "Cash and cash equivalents"}) + iex> Bookkeeping.update_account(account, %{name: "Cash and cash equivalents"}) {:ok, %Bookkeeping.Core.Account{...}} + + iex> Bookkeeping.update_account(account, %{name: "Cash and cash equivalents"}) + {:error, :invalid_account} + + iex> Bookkeeping.update_account(account, %{code: "1002"}) + {:error, :invalid_field} + + iex> Bookkeeping.update_account(account, nil) + {:error, :invalid_params} """ - @spec update_account(Account.t(), map()) :: {:ok, Account.t()} | {:error, :invalid_account} - defdelegate update_account(account, attrs), to: ChartOfAccounts + @spec update_account(Account.t(), map()) :: + {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} + def update_account(account, params), do: ChartOfAccounts.update(account, params) @doc """ Returns all accounts. @@ -107,39 +123,47 @@ defmodule Bookkeeping do {:ok, [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...]} """ @spec all_accounts() :: {:ok, list(Account.t())} - defdelegate all_accounts, to: ChartOfAccounts + def all_accounts(), do: ChartOfAccounts.all_accounts() @doc """ - Finds an account by code. + Search accounts by code. Arguments: - code: The unique code of the account. - Returns `{:ok, account}` if the account was found, otherwise `{:error, :not_found}`. + Returns `{:ok, accounts}` whether the account is found or not. If the input is invalid, returns `{:error, :invalid_code}`. ## Examples - iex> Bookkeeping.find_account_by_code(server, "1000") - {:ok, %Bookkeeping.Core.Account{...}} + iex> Bookkeeping.search_accounts_by_code(server, "1000") + {:ok, [%Bookkeeping.Core.Account{...}, ...]} + + iex> Bookkeeping.search_accounts_by_code(server, nil) + {:error, :invalid_code} """ - @spec find_account_by_code(String.t()) :: {:ok, Account.t()} | {:error, :not_found} - defdelegate find_account_by_code(code), to: ChartOfAccounts + @spec search_accounts_by_code(Account.account_code()) :: + {:ok, list(Account.t())} | {:error, :not_found | :invalid_code} + def search_accounts_by_code(code), do: ChartOfAccounts.search_code(code) @doc """ - Finds an account by name. + Search accounts by name. Arguments: - name: The unique name of the account. - Returns `{:ok, account}` if the account was found, otherwise `{:error, :not_found}`. + Returns `{:ok, accounts}` whether the account is found or not. If the input is invalid, returns `{:error, :invalid_name}`. ## Examples - iex> Bookkeeping.find_account_by_name(server, "Cash") - {:ok, %Bookkeeping.Core.Account{...}} + iex> Bookkeeping.search_accounts_by_name(server, "Cash") + {:ok, [%Bookkeeping.Core.Account{...}, ...]} + + iex> Bookkeeping.search_accounts_by_name(server, nil) + {:error, :invalid_name} """ - @spec find_account_by_name(String.t()) :: {:ok, Account.t()} | {:error, :not_found} - defdelegate find_account_by_name(name), to: ChartOfAccounts + @spec search_accounts_by_name(String.t()) :: + {:ok, list(Account.t())} | {:error, :not_found | :invalid_name} + def search_accounts_by_name(name), do: ChartOfAccounts.search_name(name) @doc """ Search accounts by code or name. @@ -147,243 +171,219 @@ defmodule Bookkeeping do Arguments: - query: The query to search for code or name. - Returns `{:ok, accounts}` if the account was found, otherwise `{:ok, []}`. + Returns `{:ok, accounts}` whether the account is found or not. If the input is invalid, returns `{:error, :invalid_name}`. ## Examples - iex> Bookkeeping.search_accounts(server, "1000") + iex> Bookkeeping.search_accounts("1000") {:ok, [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...]} - """ - @spec search_accounts(String.t()) :: {:ok, list(Account.t())} | {:error, :invalid_query} - defdelegate search_accounts(code_or_name), to: ChartOfAccounts - @doc """ - Get all accounts sorted by code or name. - - Returns `{:ok, accounts}` if the accounts were sorted successfully, otherwise `{:error, :invalid_field}`. - - ## Examples - - iex> Bookkeeping.all_sorted_accounts(server, :code) + iex> Bookkeeping.search_accounts("Cash") {:ok, [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...]} - """ - @spec all_sorted_accounts(String.t()) :: {:ok, list(Account.t())} | {:error, :invalid_field} - defdelegate all_sorted_accounts(account_field), to: ChartOfAccounts - @doc """ - Resets the accounts. - - Returns `{:ok, []}`. - - ## Examples - - iex> Bookkeeping.reset_accounts(server) + iex> Bookkeeping.search_accounts("invalid") {:ok, []} - """ - @spec reset_accounts() :: {:ok, list(Account.t())} - defdelegate reset_accounts, to: ChartOfAccounts - @doc """ - Returns the state of the chart of accounts. - - Returns `{:ok, state}`. - - ## Examples - - iex> Bookkeeping.get_chart_of_accounts_state() - {:ok, %{...}} + iex> Bookkeeping.search_accounts(nil) + {:error, :invalid_name} """ - @spec get_chart_of_accounts_state() :: {:ok, ChartOfAccounts.chart_of_account_state()} - defdelegate get_chart_of_accounts_state, to: ChartOfAccounts + @spec search_accounts(String.t()) :: + {:ok, list(Account.t())} | {:error, :invalid_code | :invalid_name} + def search_accounts(code_or_name) do + with {:ok, accounts_based_on_name} <- ChartOfAccounts.search_name(code_or_name), + {:ok, accounts_based_on_code} <- ChartOfAccounts.search_code(code_or_name) do + {:ok, accounts_based_on_code ++ accounts_based_on_name} + end + end ########################################################## # Accounting Journal Functions # ########################################################## - @doc """ - Creates a journal entry. + # @doc """ + # Creates a journal entry. + + # Arguments: + # - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) + # - general_ledger_posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. + # - t_accounts: The map of line items. The map must have the following keys: + # - left: The list of maps with account and amount field and represents the entry type of debit. + # - right: The list of maps with account and amount field and represents the entry type of credit. + # - journal_entry_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). + # - transaction_reference_number (optional): The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) + # - journal_entry_description (optional): The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) + # - journal_entry_details (optional): The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) + # - audit_details (optional): The details of the audit log. + + # Returns `{:ok, JournalEntry.t()}` if the journal entry is created successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. + + # ## Examples + + # iex> Bookkeeping.create_journal_entry(%{ + # ...> transaction_date: ~U[2021-10-10 10:10:10.000000Z], + # ...> general_ledger_posting_date: ~U[2021-10-10 10:10:10.000000Z], + # ...> t_accounts: %{ + # ...> left: [ + # ...> %{ + # ...> account: "Cash", + # ...> amount: Decimal.new(100) + # ...> } + # ...> ], + # ...> right: [ + # ...> %{ + # ...> account: "Sales Revenue", + # ...> amount: Decimal.new(100) + # ...> } + # ...> ]F + # ...> }, + # ...> journal_entry_number: "JE001001", + # ...> transaction_reference_number: "INV001001", + # ...> journal_entry_description: "description", + # ...> journal_entry_details: %{}, + # ...> audit_details: %{} + # ...> }) + # %{:ok, %Bookkeeping.Core.JournalEntry{...}} + # """ + # @spec create_journal_entry(AccountingJournal.create_journal_entry_params()) :: + # {:ok, JournalEntry.t()} | {:error, :invalid_journal_entry} + # defdelegate create_journal_entry(create_journal_entry_params), to: AccountingJournal + + # @doc """ + # Imports journal entries from a CSV file. + # The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Account Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` + + # Arguments: + # - path: The path of the CSV file. + + # Returns `{:ok, %{ok: list(JournalEntry.t()), error: list(map())}}` if the journal entries are imported successfully. Otherwise, returns `{:error, %{message: :invalid_csv, errors: list(map())}}`. + + # ## Examples + + # iex> Bookkeeping.import_journal_entries(server, "../../data/sample_journal_entries.csv") + # {:ok, + # %{ + # error: [], + # ok: [%Bookkeeping.Core.JournalEntry{...}, %Bookkeeping.Core.JournalEntry{...}, ...] + # }} + # """ + # @spec import_journal_entries(String.t()) :: + # {:ok, %{ok: list(JournalEntry.t()), error: list(map())}} + # | {:error, %{ok: list(JournalEntry.t()), error: list(map())}} + # | {:error, %{message: :invalid_csv, errors: list(map())}} + # | {:error, :invalid_file} + # defdelegate import_journal_entries(file_path), to: AccountingJournal + + # @doc """ + # Returns all journal entries. + + # Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. + + # ## Examples + + # iex> Bookkeeping.all_journal_entries() + # {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} + # """ + # @spec all_journal_entries() :: {:ok, list(JournalEntry.t())} + # defdelegate all_journal_entries, to: AccountingJournal + + # @spec find_journal_entry_by_journal_entry_number(String.t()) :: + # {:ok, JournalEntry.t()} | {:error, :not_found} + # defdelegate find_journal_entry_by_journal_entry_number(journal_entry_number), + # to: AccountingJournal + + # @spec find_journal_entries_by_general_ledger_posting_date( + # DateTime.t() + # | AccountingJournal.general_ledger_posting_date_details() + # ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} + # defdelegate find_journal_entries_by_general_ledger_posting_date(datetime), to: AccountingJournal + + # @doc """ + # Returns a journal entry by id. + + # Returns `{:ok, JournalEntry.t()}` if the journal entry is returned successfully. Otherwise, returns `{:error, :invalid_id}`. + + # ## Examples + + # iex> Bookkeeping.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + # {:ok, %JournalEntry{...}} + + # iex> Bookkeeping.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + # {:error, :invalid_id} + # """ + # @spec find_journal_entries_by_id(String.t()) :: {:ok, JournalEntry.t()} | {:error, :invalid_id} + # defdelegate find_journal_entries_by_id(id), to: AccountingJournal + + # @doc """ + # Returns a list of journal entries by general ledger posting date range. + + # Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. Otherwise, returns `{:error, :invalid_date}`. + + # ## Examples + + # iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) + # {:ok, [%JournalEntry{...}]} + + # iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(%{year: 2021, month: 10, day: 10}, %{year: 2021, month: 10, day: 10}) + # {:ok, [%JournalEntry{...}]} + + # iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) + # {:error, :invalid_date} + # """ + # @spec find_journal_entries_by_general_ledger_posting_date_range( + # DateTime.t() | AccountingJournal.general_ledger_posting_date_details(), + # DateTime.t() | AccountingJournal.general_ledger_posting_date_details() + # ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} + # defdelegate find_journal_entries_by_general_ledger_posting_date_range( + # from_datetime, + # to_datetime + # ), + # to: AccountingJournal + + # @doc """ + # Updates a journal entry. - Arguments: - - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) - - general_ledger_posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. - - t_accounts: The map of line items. The map must have the following keys: - - left: The list of maps with account and amount field and represents the entry type of debit. - - right: The list of maps with account and amount field and represents the entry type of credit. - - journal_entry_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). - - transaction_reference_number (optional): The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) - - journal_entry_description (optional): The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) - - journal_entry_details (optional): The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) - - audit_details (optional): The details of the audit log. - - Returns `{:ok, JournalEntry.t()}` if the journal entry is created successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. + # Returns `{:ok, JournalEntry.t()}` if the journal entry is updated successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. - ## Examples + # ## Examples - iex> Bookkeeping.create_journal_entry(%{ - ...> transaction_date: ~U[2021-10-10 10:10:10.000000Z], - ...> general_ledger_posting_date: ~U[2021-10-10 10:10:10.000000Z], - ...> t_accounts: %{ - ...> left: [ - ...> %{ - ...> account: "Cash", - ...> amount: Decimal.new(100) - ...> } - ...> ], - ...> right: [ - ...> %{ - ...> account: "Sales Revenue", - ...> amount: Decimal.new(100) - ...> } - ...> ]F - ...> }, - ...> journal_entry_number: "JE001001", - ...> transaction_reference_number: "INV001001", - ...> journal_entry_description: "description", - ...> journal_entry_details: %{}, - ...> audit_details: %{} - ...> }) - %{:ok, %Bookkeeping.Core.JournalEntry{...}} - """ - @spec create_journal_entry(AccountingJournal.create_journal_entry_params()) :: - {:ok, JournalEntry.t()} | {:error, :invalid_journal_entry} - defdelegate create_journal_entry(create_journal_entry_params), to: AccountingJournal + # iex> Bookkeeping.find_journal_entry_by_journal_entry_number("ref_num_1") + # {:ok, %JournalEntry{...}} - @doc """ - Imports journal entries from a CSV file. - The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Account Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` + # iex> Bookkeeping.update_journal_entry(%JournalEntry{...}, %{journal_entry_description: "updated description",posted: true}) + # {:ok, %JournalEntry{journal_entry_description: "updated description", posted: true, ...}} - Arguments: - - path: The path of the CSV file. + # iex> Bookkeeping.update_journal_entry(%JournalEntry{}, %{journal_entry_description: "updated description",posted: true}) + # {:error, :invalid_journal_entry} + # """ + # @spec update_journal_entry(JournalEntry.t(), map()) :: + # {:ok, JournalEntry.t()} + # | {:error, :invalid_journal_entry} + # | {:error, :already_posted_journal_entry} + # defdelegate update_journal_entry(journal_entry, attrs), to: AccountingJournal - Returns `{:ok, %{ok: list(JournalEntry.t()), error: list(map())}}` if the journal entries are imported successfully. Otherwise, returns `{:error, %{message: :invalid_csv, errors: list(map())}}`. + # @doc """ + # Resets the journal entries. - ## Examples + # Returns `{:ok, list(JournalEntry.t())}` if the journal entries are reset successfully. - iex> Bookkeeping.import_journal_entries(server, "../../data/sample_journal_entries.csv") - {:ok, - %{ - error: [], - ok: [%Bookkeeping.Core.JournalEntry{...}, %Bookkeeping.Core.JournalEntry{...}, ...] - }} - """ - @spec import_journal_entries(String.t()) :: - {:ok, %{ok: list(JournalEntry.t()), error: list(map())}} - | {:error, %{ok: list(JournalEntry.t()), error: list(map())}} - | {:error, %{message: :invalid_csv, errors: list(map())}} - | {:error, :invalid_file} - defdelegate import_journal_entries(file_path), to: AccountingJournal - - @doc """ - Returns all journal entries. - - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. - - ## Examples - - iex> Bookkeeping.all_journal_entries() - {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} - """ - @spec all_journal_entries() :: {:ok, list(JournalEntry.t())} - defdelegate all_journal_entries, to: AccountingJournal - - @spec find_journal_entry_by_journal_entry_number(String.t()) :: - {:ok, JournalEntry.t()} | {:error, :not_found} - defdelegate find_journal_entry_by_journal_entry_number(journal_entry_number), - to: AccountingJournal - - @spec find_journal_entries_by_general_ledger_posting_date( - DateTime.t() - | AccountingJournal.general_ledger_posting_date_details() - ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} - defdelegate find_journal_entries_by_general_ledger_posting_date(datetime), to: AccountingJournal - - @doc """ - Returns a journal entry by id. - - Returns `{:ok, JournalEntry.t()}` if the journal entry is returned successfully. Otherwise, returns `{:error, :invalid_id}`. - - ## Examples - - iex> Bookkeeping.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - {:ok, %JournalEntry{...}} - - iex> Bookkeeping.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - {:error, :invalid_id} - """ - @spec find_journal_entries_by_id(String.t()) :: {:ok, JournalEntry.t()} | {:error, :invalid_id} - defdelegate find_journal_entries_by_id(id), to: AccountingJournal - - @doc """ - Returns a list of journal entries by general ledger posting date range. - - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. Otherwise, returns `{:error, :invalid_date}`. - - ## Examples + # ## Examples - iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) - {:ok, [%JournalEntry{...}]} + # iex> Bookkeeping.reset_journal_entries() + # {:ok, []} + # """ + # @spec reset_journal_entries() :: {:ok, list(JournalEntry.t())} + # defdelegate reset_journal_entries, to: AccountingJournal - iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(%{year: 2021, month: 10, day: 10}, %{year: 2021, month: 10, day: 10}) - {:ok, [%JournalEntry{...}]} + # @doc """ + # Returns the state of the accounting journal. - iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) - {:error, :invalid_date} - """ - @spec find_journal_entries_by_general_ledger_posting_date_range( - DateTime.t() | AccountingJournal.general_ledger_posting_date_details(), - DateTime.t() | AccountingJournal.general_ledger_posting_date_details() - ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} - defdelegate find_journal_entries_by_general_ledger_posting_date_range( - from_datetime, - to_datetime - ), - to: AccountingJournal - - @doc """ - Updates a journal entry. - - Returns `{:ok, JournalEntry.t()}` if the journal entry is updated successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. - - ## Examples - - iex> Bookkeeping.find_journal_entry_by_journal_entry_number("ref_num_1") - {:ok, %JournalEntry{...}} - - iex> Bookkeeping.update_journal_entry(%JournalEntry{...}, %{journal_entry_description: "updated description",posted: true}) - {:ok, %JournalEntry{journal_entry_description: "updated description", posted: true, ...}} - - iex> Bookkeeping.update_journal_entry(%JournalEntry{}, %{journal_entry_description: "updated description",posted: true}) - {:error, :invalid_journal_entry} - """ - @spec update_journal_entry(JournalEntry.t(), map()) :: - {:ok, JournalEntry.t()} - | {:error, :invalid_journal_entry} - | {:error, :already_posted_journal_entry} - defdelegate update_journal_entry(journal_entry, attrs), to: AccountingJournal - - @doc """ - Resets the journal entries. + # Returns `{:ok, state}`. - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are reset successfully. + # ## Examples - ## Examples - - iex> Bookkeeping.reset_journal_entries() - {:ok, []} - """ - @spec reset_journal_entries() :: {:ok, list(JournalEntry.t())} - defdelegate reset_journal_entries, to: AccountingJournal - - @doc """ - Returns the state of the accounting journal. - - Returns `{:ok, state}`. - - ## Examples - - iex> Bookkeeping.get_accounting_journal_state() - {:ok, %{...}} - """ - @spec get_accounting_journal_state() :: {:ok, AccountingJournal.accounting_journal_state()} - defdelegate get_accounting_journal_state, to: AccountingJournal + # iex> Bookkeeping.get_accounting_journal_state() + # {:ok, %{...}} + # """ + # @spec get_accounting_journal_state() :: {:ok, AccountingJournal.accounting_journal_state()} + # defdelegate get_accounting_journal_state, to: AccountingJournal end diff --git a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex index aecb206..71684fa 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -50,11 +50,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do @spec all_accounts() :: {:ok, list(Account.t())} def all_accounts, do: maybe_handle_call(:all_accounts) - @spec search_code(Account.account_code()) :: - {:ok, Account.t()} | {:error, :not_found | :invalid_code} + @spec search_code(Account.account_code()) :: {:ok, list(Account.t())} | {:error, :invalid_code} def search_code(code), do: maybe_handle_call({:search_code, code}) - @spec search_name(String.t()) :: {:ok, Account.t()} | {:error, :not_found} + @spec search_name(String.t()) :: {:ok, list(Account.t())} | {:error, :invalid_name} def search_name(name), do: maybe_handle_call({:search_name, name}) @spec die() :: :ok diff --git a/test/bookkeeping_test.exs b/test/bookkeeping_test.exs index eac53b0..07571be 100644 --- a/test/bookkeeping_test.exs +++ b/test/bookkeeping_test.exs @@ -1,393 +1,283 @@ defmodule BookkeepingTest do use ExUnit.Case alias Bookkeeping + alias Bookkeeping.Boundary.ChartOfAccounts alias Bookkeeping.Core.{Account, JournalEntry} - test "create account" do - assert {:ok, _account} = - Bookkeeping.create_account( - "1000_bookkeeping_test", - "Cash_bookkeeping_test", - "asset", - "Cash in Bank", - %{} - ) - - assert {:error, :account_already_exists} = - Bookkeeping.create_account( - "1000_bookkeeping_test", - "Cash_bookkeeping_test", - "asset", - "Cash in Bank", - %{} - ) - end - - test "import accounts" do - assert {:ok, []} = Bookkeeping.reset_accounts() - assert {:ok, []} = Bookkeeping.all_accounts() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_accounts( - "../../../../test/bookkeeping/data/valid_bookkeeping_accounts.csv" - ) - - assert {:error, :invalid_file} = - Bookkeeping.import_accounts("test/bookkeeping/data/valid_bookkeeping_accounts.csv") - end - - test "update account" do - assert {:ok, account} = - Bookkeeping.create_account( - "1000_bookkeeping_test_for_update", - "Cash_bookkeeping_test_for_update", - "asset", - "Cash in Bank", - %{} - ) - - assert {:ok, _account} = - Bookkeeping.update_account( - account, - %{name: "Cash_bookkeeping_test_updated"} - ) - - assert {:error, :invalid_account} = - Bookkeeping.update_account( - %Account{}, - %{name: "Cash_bookkeeping_test_updated"} - ) - end + setup do + params = %{ + code: "1000", + name: "Cash", + classification: "asset", + description: "description", + audit_details: %{email: "example@example.com"}, + active: true + } - test "all accounts" do - assert {:ok, _accounts} = Bookkeeping.all_accounts() - end + invalid_params = %{ + code: "1000", + name: "Cash", + classification: "invalid", + description: "description", + audit_details: %{email: "example@example.com"}, + active: true + } - test "find account by code" do - assert {:ok, _account} = - Bookkeeping.create_account( - "1000_bookkeeping_test_for_find_by_code", - "Cash_bookkeeping_test_for_find_by_code", - "asset", - "Cash in Bank", - %{} - ) - - assert {:ok, _account} = - Bookkeeping.find_account_by_code("1000_bookkeeping_test_for_find_by_code") - - assert {:error, :not_found} = - Bookkeeping.find_account_by_code("1000_bookkeeping_test_for_find_by_code_not_found") + {:ok, params: params, invalid_params: invalid_params} end - test "find account by name" do - assert {:ok, _account} = - Bookkeeping.create_account( - "1000_bookkeeping_test_for_find_by_name", - "Cash_bookkeeping_test_for_find_by_name", - "asset", - "Cash in Bank", - %{} - ) - - assert {:ok, _account} = - Bookkeeping.find_account_by_name("Cash_bookkeeping_test_for_find_by_name") - - assert {:error, :not_found} = - Bookkeeping.find_account_by_name("Cash_bookkeeping_test_for_find_by_name_not_found") + ########################################################## + # Chart of Accounts Tests # + ########################################################## + + describe "Bookkeeping create_account/1 " do + test "with valid params", %{params: params} do + params = update_params(params) + assert {:ok, account} = Bookkeeping.create_account(params) + assert account.code == params.code + assert account.name == params.name + assert account.classification.name == "Asset" + assert account.description == "description" + assert is_struct(account.classification, Bookkeeping.Core.Account.Classification) + assert is_list(account.audit_logs) + end + + test "with invalid params" do + assert {:error, :invalid_params} = Bookkeeping.create_account("apple") + assert {:error, :invalid_params} = Bookkeeping.create_account(%{}) + assert {:error, :invalid_params} = Bookkeeping.create_account(%{code: "1000"}) + assert {:error, :invalid_params} = Bookkeeping.create_account(%{name: "Cash"}) + assert {:error, :invalid_params} = Bookkeeping.create_account(%{classification: "asset"}) + assert {:error, :invalid_params} = Bookkeeping.create_account(%{description: "description"}) + + assert {:error, :invalid_params} = + Bookkeeping.create_account(%{audit_details: %{email: "example@example.com"}}) + + assert {:error, :invalid_params} = Bookkeeping.create_account(%{active: true}) + end + + test "with invalid field", %{invalid_params: invalid_params} do + params = update_params(invalid_params) + assert {:error, :invalid_field} = Bookkeeping.create_account(params) + end + + test "that already exists", %{params: params} do + params = update_params(params) + assert {:ok, _account} = Bookkeeping.create_account(params) + assert {:error, :already_exists} = Bookkeeping.create_account(params) + end + + test "with invalid table" do + ChartOfAccounts.Worker.die() + assert {:error, :invalid_params} = Bookkeeping.create_account(%{code: "1000", name: "Cash"}) + end end - test "search accounts" do - assert {:ok, _accounts} = Bookkeeping.search_accounts("Cash_bookkeeping_test") - end + describe "Bookkeeping import_accounts/1" do + test "with a valid file twice" do + assert %{accounts: accounts, errors: _errors} = + Bookkeeping.import_accounts( + "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" + ) - test "all sorted accounts" do - assert {:ok, _accounts} = Bookkeeping.all_sorted_accounts("code") - assert {:ok, _accounts} = Bookkeeping.all_sorted_accounts("name") - assert {:error, :invalid_field} = Bookkeeping.all_sorted_accounts("classification") - assert {:error, :invalid_field} = Bookkeeping.all_sorted_accounts(nil) - end + assert length(accounts) == 9 - test "reset accounts" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - end + Process.sleep(300) - test "get chart of accounts state" do - assert {:ok, state} = Bookkeeping.get_chart_of_accounts_state() - assert is_map(state) - end + assert %{accounts: [], errors: errors} = + Bookkeeping.import_accounts( + "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" + ) - test "create journal entry" do - transaction_date = DateTime.utc_now() - general_ledger_posting_date = DateTime.utc_now() - journal_entry_number = "JE100100_Bookkeeping_Test" - transaction_reference_number = "INV100100" - journal_entry_description = "journal entry description" - audit_details = %{email: "example@example.com"} - - assert {:ok, cash_account} = - Bookkeeping.create_account( - "1000_000_bookkeeping_test", - "1000_000_Cash_bookkeeping_test", - "asset", - "Cash in Bank", - %{} - ) - - {:ok, revenue_account} = - Bookkeeping.create_account( - "20_000_000_bookkeeping_test", - "20_000_000_Sales_revenue_bookkeeping_test", - "revenue", - "sales revenue description", - %{} - ) - - t_accounts = %{ - left: [%{account: cash_account, amount: Decimal.new(100)}], - right: [%{account: revenue_account, amount: Decimal.new(100)}] - } + assert Enum.count(errors) == 9 + assert Enum.all?(errors, fn error -> error.reason == :already_exists end) - journal_entry_details = %{approved_by: "John Doe", approved_at: DateTime.utc_now()} - - create_je_params = %{ - transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, - t_accounts: t_accounts, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_description: journal_entry_description, - journal_entry_details: journal_entry_details, - audit_details: audit_details - } + assert %{accounts: [], errors: _errors} = + Bookkeeping.import_accounts( + "../../../../test/bookkeeping/data/empty_chart_of_accounts_2.csv" + ) + end - assert {:ok, _journal_entry_0} = Bookkeeping.create_journal_entry(create_je_params) + test "with an invalid file" do + assert {:error, :invalid_file} = + Bookkeeping.import_accounts("../../../../test/bookkeeping/data/invalid_file.csv") - assert {:error, :duplicate_journal_entry_number} = - Bookkeeping.create_journal_entry(create_je_params) - end + assert {:error, :invalid_file} = + Bookkeeping.import_accounts( + "../../../../test/bookkeeping/data/empty_chart_of_accounts.csv" + ) - test "import journal entries" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - assert {:ok, []} = Bookkeeping.all_accounts() + assert {:error, :invalid_file} = + Bookkeeping.import_accounts("../../../../test/bookkeeping/data/text_file.txt") - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_accounts( - "../../../../test/bookkeeping/data/valid_bookkeeping_accounts.csv" - ) + assert {:error, :invalid_file} = Bookkeeping.import_accounts(nil) + end - assert {:ok, []} = Bookkeeping.reset_journal_entries() - assert {:ok, []} = Bookkeeping.all_journal_entries() + test "with a file with invalid values" do + assert %{accounts: accounts, errors: errors} = + Bookkeeping.import_accounts( + "../../../../test/bookkeeping/data/partially_valid_chart_of_accounts.csv" + ) - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_journal_entries( - "../../../../test/bookkeeping/data/valid_bookkeeping_journal_entries.csv" - ) + assert Enum.count(accounts) == 7 + assert Enum.count(errors) == 3 - assert {:error, :invalid_file} = - Bookkeeping.import_journal_entries( - "test/bookkeeping/data/valid_bookkeeping_journal_entries.csv" - ) + assert Enum.all?(errors, fn error -> + error.reason in [:already_exists, :invalid_field] + end) + end end - test "all journal entries" do - assert {:ok, _journal_entries} = Bookkeeping.all_journal_entries() + describe "Bookkeeping update_accounts/2" do + test "with valid params", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + + assert {:ok, updated_account} = + Bookkeeping.update_account(account, %{ + name: "Cash updated", + description: "description updated", + audit_details: %{updated_by: "example@example.com"}, + active: false + }) + + assert updated_account.code == account.code + assert updated_account.name == "Cash updated" + assert updated_account.classification.name == "Asset" + assert updated_account.description == "description updated" + assert is_struct(updated_account.classification, Bookkeeping.Core.Account.Classification) + assert is_list(updated_account.audit_logs) + assert length(updated_account.audit_logs) == 2 + assert updated_account.active == false + end + + test "with invalid account" do + assert {:error, :invalid_account} = Bookkeeping.update_account(nil, %{name: "Cash updated"}) + + assert {:error, :invalid_account} = + Bookkeeping.update_account("apple", %{name: "Cash updated"}) + end + + test "with invalid field", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + + assert {:error, :invalid_field} = Bookkeeping.update_account(account, %{code: "1001"}) + + assert {:error, :invalid_field} = + Bookkeeping.update_account(account, %{classification: "liability"}) + + assert {:error, :invalid_field} = Bookkeeping.update_account(account, %{test: "test"}) + end + + test "with invalid params", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + + assert {:error, :invalid_params} = Bookkeeping.update_account(account, nil) + assert {:error, :invalid_params} = Bookkeeping.update_account(account, "apple") + assert {:error, :invalid_params} = Bookkeeping.update_account(account, %{}) + end end - test "find journal entry by journal entry number" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - assert {:ok, []} = Bookkeeping.all_accounts() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_accounts( - "../../../../test/bookkeeping/data/valid_bookkeeping_accounts.csv" - ) - - assert {:ok, []} = Bookkeeping.reset_journal_entries() - assert {:ok, []} = Bookkeeping.all_journal_entries() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_journal_entries( - "../../../../test/bookkeeping/data/valid_bookkeeping_journal_entries.csv" - ) - - assert {:ok, _journal_entry} = - Bookkeeping.find_journal_entry_by_journal_entry_number("1001_Bookkeeping_Test") - - assert {:error, :not_found} = - Bookkeeping.find_journal_entry_by_journal_entry_number( - "JE100100_Bookkeeping_Test_not_found" - ) + describe "Bookkeeping all_accounts/0" do + test "returns all accounts", %{params: params} do + {:ok, account} = Bookkeeping.create_account(params) + assert {:ok, accounts} = Bookkeeping.all_accounts() + assert account in accounts + end end - test "find journal entry by journal entry id" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - assert {:ok, []} = Bookkeeping.all_accounts() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_accounts( - "../../../../test/bookkeeping/data/valid_bookkeeping_accounts.csv" - ) - - assert {:ok, []} = Bookkeeping.reset_journal_entries() - assert {:ok, []} = Bookkeeping.all_journal_entries() - - assert {:ok, %{ok: ok, error: _error}} = - Bookkeeping.import_journal_entries( - "../../../../test/bookkeeping/data/valid_bookkeeping_journal_entries.csv" - ) - - first_journal_entry_id = ok |> List.first() |> Map.get(:id) - - assert {:ok, journal_entry} = Bookkeeping.find_journal_entries_by_id(first_journal_entry_id) - assert journal_entry.id == first_journal_entry_id - assert {:error, :invalid_id} = Bookkeeping.find_journal_entries_by_id(nil) + describe "Bookkeeping search_accounts_by_code/1" do + test "with complete code", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + assert {:ok, accounts} = Bookkeeping.search_accounts_by_code(account.code) + assert Enum.member?(accounts, account) + code_prefix = String.slice(account.code, 0, 2) + assert {:ok, accounts} = Bookkeeping.search_accounts_by_code(code_prefix) + assert account in accounts + end + + test "with code prefix", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + + code_prefix = String.slice(account.code, 0, 2) + assert {:ok, accounts} = Bookkeeping.search_accounts_by_code(code_prefix) + assert Enum.member?(accounts, account) + end + + test "with invalid code" do + assert {:error, :invalid_code} = Bookkeeping.search_accounts_by_code(nil) + assert {:error, :invalid_code} = Bookkeeping.search_accounts_by_code(%{}) + assert {:error, :invalid_code} = Bookkeeping.search_accounts_by_code("") + end end - test "find journal entries by general ledger posting date" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - assert {:ok, []} = Bookkeeping.all_accounts() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_accounts( - "../../../../test/bookkeeping/data/valid_bookkeeping_accounts.csv" - ) - - assert {:ok, []} = Bookkeeping.reset_journal_entries() - assert {:ok, []} = Bookkeeping.all_journal_entries() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_journal_entries( - "../../../../test/bookkeeping/data/valid_bookkeeping_journal_entries.csv" - ) - - assert {:ok, []} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date(DateTime.utc_now()) - - {:ok, datetime, _} = DateTime.from_iso8601("2023-08-12T00:00:00Z") - - assert {:ok, journal_entries} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date(datetime) - - refute journal_entries == [] - assert length(journal_entries) == 2 - - assert {:error, :invalid_date} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date("invalid_date") + describe "Bookkeeping search_accounts_by_name/1" do + test "with complete name", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + assert {:ok, accounts} = Bookkeeping.search_accounts_by_name(account.name) + assert Enum.member?(accounts, account) + name_prefix = String.slice(account.name, 0, 2) + assert {:ok, accounts} = Bookkeeping.search_accounts_by_name(name_prefix) + assert account in accounts + end + + test "with name prefix", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + name_prefix = String.slice(account.name, 0, 2) + assert {:ok, accounts} = Bookkeeping.search_accounts_by_name(name_prefix) + assert account in accounts + end + + test "with invalid name" do + assert {:error, :invalid_name} = Bookkeeping.search_accounts_by_name(nil) + assert {:error, :invalid_name} = Bookkeeping.search_accounts_by_name(%{}) + assert {:error, :invalid_name} = Bookkeeping.search_accounts_by_name("") + end end - test "find journal entries by general ledger posting date range" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - assert {:ok, []} = Bookkeeping.all_accounts() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_accounts( - "../../../../test/bookkeeping/data/valid_bookkeeping_accounts.csv" - ) - - assert {:ok, []} = Bookkeeping.reset_journal_entries() - assert {:ok, []} = Bookkeeping.all_journal_entries() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_journal_entries( - "../../../../test/bookkeeping/data/valid_bookkeeping_journal_entries.csv" - ) - - assert {:ok, []} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range( - DateTime.utc_now(), - DateTime.utc_now() - ) - - {:ok, datetime, _} = DateTime.from_iso8601("2023-08-11T00:00:00Z") - - assert {:ok, journal_entries} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range( - datetime, - DateTime.utc_now() - ) - - assert journal_entries != [] - assert length(journal_entries) == 2 - - {:ok, datetime, _} = DateTime.from_iso8601("2023-08-12T00:00:00Z") - - assert {:ok, journal_entries_2} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range( - datetime, - %{year: 2023, month: 9, day: 1} - ) - - assert journal_entries_2 != [] - assert length(journal_entries_2) == 2 - - assert {:error, :invalid_date} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range( - "invalid_date", - DateTime.utc_now() - ) - - assert {:error, :invalid_date} = - Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range( - DateTime.utc_now(), - "invalid_date" - ) + describe "Bookkeeping search_accounts/1" do + test "with complete name", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + assert {:ok, accounts} = Bookkeeping.search_accounts(account.name) + assert Enum.member?(accounts, account) + name_prefix = String.slice(account.name, 0, 2) + assert {:ok, accounts} = Bookkeeping.search_accounts(name_prefix) + assert account in accounts + end + + test "with name prefix", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + name_prefix = String.slice(account.name, 0, 2) + assert {:ok, accounts} = Bookkeeping.search_accounts(name_prefix) + assert account in accounts + end + + test "with invalid name" do + assert {:error, :invalid_name} = Bookkeeping.search_accounts(nil) + assert {:error, :invalid_name} = Bookkeeping.search_accounts(%{}) + assert {:error, :invalid_name} = Bookkeeping.search_accounts("") + end end - test "update journal entry" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - assert {:ok, []} = Bookkeeping.all_accounts() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_accounts( - "../../../../test/bookkeeping/data/valid_bookkeeping_accounts.csv" - ) - - assert {:ok, []} = Bookkeeping.reset_journal_entries() - assert {:ok, []} = Bookkeeping.all_journal_entries() - - assert {:ok, %{ok: _ok, error: _error}} = - Bookkeeping.import_journal_entries( - "../../../../test/bookkeeping/data/valid_bookkeeping_journal_entries.csv" - ) - - assert {:ok, journal_entry} = - Bookkeeping.find_journal_entry_by_journal_entry_number("1001_Bookkeeping_Test") - - assert {:ok, journal_entry} = - Bookkeeping.update_journal_entry( - journal_entry, - %{journal_entry_description: "updated journal entry description"} - ) - - assert {:ok, first_journal_entry_update} = - Bookkeeping.update_journal_entry( - journal_entry, - %{ - journal_entry_description: "first journal entry description update", - posted: true - } - ) - - assert {:error, :already_posted_journal_entry} = - Bookkeeping.update_journal_entry( - first_journal_entry_update, - %{journal_entry_description: "second journal entry description update"} - ) - - assert {:error, :invalid_journal_entry} = - Bookkeeping.update_journal_entry( - %JournalEntry{}, - %{journal_entry_description: "updated journal entry description", posted: true} - ) - end + ########################################################## + # Accounting Journal Tests # + ########################################################## - test "reset journal entries" do - assert {:ok, _journal_entries} = Bookkeeping.reset_journal_entries() + defp update_params(params) do + code = random_string() + name = random_string() + Map.merge(params, %{code: code, name: name}) end - test "get accounting journal state" do - assert {:ok, state} = Bookkeeping.get_accounting_journal_state() - assert is_map(state) + defp random_string do + for _ <- 1..10, into: "", do: <> end end From 4b96ee1e7f00ac93f9e454684211e48cc6b8b03e Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 21 Dec 2023 19:17:37 +0800 Subject: [PATCH 24/32] rework the audit_log core --- lib/bookkeeping/core/account.ex | 30 +++++++- lib/bookkeeping/core/audit_log.ex | 77 +++++++++++++------ lib/bookkeeping/core/journal_entry.ex | 14 +++- .../bookkeeping/core/account_benchmark.exs | 8 +- test/bookkeeping/core/audit_log_test.exs | 64 ++++++--------- 5 files changed, 125 insertions(+), 68 deletions(-) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 4f5ea59..bd38625 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -225,7 +225,13 @@ defmodule Bookkeeping.Core.Account do Creates a new account struct. Arguments: - - params: The parameters of the account. The parameters must include the following fields: `code`, `name`, `description`, `classification`, `audit_details`, and `active`. + - params: The parameters of the account. It must contain the following keys: + - code: The code of the account. + - name: The name of the account. + - description: The description of the account. + - classification: The classification of the account. + - audit_details: The details of the audit log. + - active: The status of the account. Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_params}` or `{:error, :invalid_field}`. @@ -250,7 +256,11 @@ defmodule Bookkeeping.Core.Account do Arguments: - account: The account to be updated. - - attrs: The attributes to be updated. The editable attributes are `name`, `description`, `active`, and `audit_details`. + - attrs: The attributes to be updated. The editable attributes are: + - name: The name of the account. + - description: The description of the account. + - active: The status of the account. + - audit_details: The details of the audit log. Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`, `{:error, :invalid_field}`, or `{:error, :invalid_params}`. @@ -330,7 +340,13 @@ defmodule Bookkeeping.Core.Account do audit_details: audit_details, active: active }) do - {:ok, audit_log} = AuditLog.create("account", "create", audit_details) + {:ok, audit_log} = + AuditLog.create(%{ + record_type: "account", + action_type: "create", + audit_details: audit_details + }) + classification = Classification.classify(classification) {:ok, @@ -375,7 +391,13 @@ defmodule Bookkeeping.Core.Account do defp verify_update_field(key, value, acc, account) when key == :audit_details and is_map(value) do - {:ok, audit_log} = AuditLog.create("account", "update", value) + {:ok, audit_log} = + AuditLog.create(%{ + record_type: "account", + action_type: "update", + audit_details: value + }) + Map.put(acc, :audit_logs, [audit_log | account.audit_logs]) end diff --git a/lib/bookkeeping/core/audit_log.ex b/lib/bookkeeping/core/audit_log.ex index 51b8363..ae1f321 100644 --- a/lib/bookkeeping/core/audit_log.ex +++ b/lib/bookkeeping/core/audit_log.ex @@ -5,6 +5,11 @@ defmodule Bookkeeping.Core.AuditLog do in the general ledger that is used to sort and store transactions. It is also used to track changes to records like accounts. """ + alias Bookkeeping.Core.AuditLog + + @typedoc """ + t type is a struct that represents an audit log. + """ @type t :: %__MODULE__{ id: UUID.t(), record_type: String.t(), @@ -15,6 +20,15 @@ defmodule Bookkeeping.Core.AuditLog do deleted_at: nil | integer() } + @typedoc """ + create_params type is a map that represents the params of the create function. + """ + @type create_params :: %{ + record_type: String.t(), + action_type: String.t(), + audit_details: map() + } + defstruct id: UUID.uuid4(), record_type: "", action_type: "", @@ -26,35 +40,54 @@ defmodule Bookkeeping.Core.AuditLog do @action_types ["create", "update", "delete"] @doc """ - Creates a new audit log struct. + Creates a new audit log struct. + + Arguments: + - params: The params of the audit log. It must contain the following keys: + - record_type: The type of the record. + - action_type: The type of the action. + - audit_details: The details of the audit log. - Arguments: - - record_type: The type of the record. - - action_type: The type of the action. - - audit_details: The details of the audit log. + Returns `{:ok, %AuditLog{}}` if the audit log is valid. Otherwise, returns `{:error, :invalid_field}` or `{:error, :invalid_params}`. - Returns `{:ok, %AuditLog{}}` if the audit log is valid. Otherwise, returns `{:error, :invalid_audit_log}`. + ## Examples - ## Examples + iex> AuditLog.create(%{record_type: "account", action_type: "create", audit_details: %{email: "test@test.com"}}) + {:ok, %AuditLog{...}} - iex> AuditLog.create("account", "create", %{email: "example@example.com"}) - {:ok, %AuditLog{...}} + iex> AuditLog.create(%{record_type: nil, action_type: "update", audit_details: %{}}) + {:error, :invalid_field} + iex> AuditLog.create(nil) + {:error, :invalid_params} + """ + @spec create(create_params()) :: + {:ok, AuditLog.t()} | {:error, :invalid_field | :invalid_params} + def create(params) do + params |> validate_create_params() |> maybe_create() + end - iex> Audit.create("account", "update", %{email: "example@example.com"}) - {:ok, %AuditLog{...}} + defp validate_create_params( + %{ + record_type: record_type, + action_type: action_type, + audit_details: audit_details + } = + params + ) do + if is_binary(record_type) and record_type != "" and is_binary(action_type) and + action_type in @action_types and is_map(audit_details), + do: params, + else: {:error, :invalid_field} + end - iex> AuditLog.create("account", "delete", %{email: "example@example.com"}) - {:ok, %AuditLog{...}} + defp validate_create_params(_), do: {:error, :invalid_params} - iex> AuditLog.create("account", "invalid", %{}) - {:error, :invalid_audit_log} - """ - @spec create(String.t(), String.t(), map()) :: - {:ok, __MODULE__.t()} | {:error, :invalid_audit_log} - def create(record_type, action_type, audit_details) - when is_binary(record_type) and record_type != "" and is_binary(action_type) and - action_type in @action_types and is_map(audit_details) do + defp maybe_create(%{ + record_type: record_type, + action_type: action_type, + audit_details: audit_details + }) do unix_datetime = DateTime.to_unix(DateTime.utc_now()) created_at = if action_type == "create", do: unix_datetime, else: nil deleted_at = if action_type == "delete", do: unix_datetime, else: nil @@ -70,5 +103,5 @@ defmodule Bookkeeping.Core.AuditLog do }} end - def create(_, _, _), do: {:error, :invalid_audit_log} + defp maybe_create({:error, reason}), do: {:error, reason} end diff --git a/lib/bookkeeping/core/journal_entry.ex b/lib/bookkeeping/core/journal_entry.ex index 393cb73..aaf370e 100644 --- a/lib/bookkeeping/core/journal_entry.ex +++ b/lib/bookkeeping/core/journal_entry.ex @@ -135,7 +135,12 @@ defmodule Bookkeeping.Core.JournalEntry do def update(journal_entry, attrs) when is_map(attrs) and map_size(attrs) > 0 and journal_entry.posted == false do with {:ok, params} <- validate_update_params(journal_entry, attrs), - {:ok, audit_log} <- AuditLog.create("journal_entry", "update", params.audit_details), + {:ok, audit_log} <- + AuditLog.create(%{ + record_type: "journal_entry", + action_type: "update", + audit_details: params.audit_details + }), {:ok, initial_je_update} <- update_dates_and_line_items( journal_entry, @@ -175,7 +180,12 @@ defmodule Bookkeeping.Core.JournalEntry do audit_details ) do with {:ok, line_items} <- LineItem.bulk_create(t_accounts), - {:ok, audit_log} <- AuditLog.create("journal_entry", "create", audit_details) do + {:ok, audit_log} <- + AuditLog.create(%{ + record_type: "journal_entry", + action_type: "create", + audit_details: audit_details + }) do {:ok, %__MODULE__{ id: UUID.uuid4(), diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs index 616c2e9..5aec36a 100644 --- a/test/benchmark/bookkeeping/core/account_benchmark.exs +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -16,7 +16,13 @@ defmodule Bookkeeping.Core.AccountBenchmark do # Account.create("1001", "Cash 1", "asset", "Cash and Cash Equivalents 1", %{}) # end, "create/1 struct only" => fn -> - audit_log = AuditLog.create("account", "create", %{}) + audit_log = + AuditLog.create(%{ + record_type: "account", + action_type: "create", + audit_details: %{} + }) + classification = Account.Classification.classify("asset") struct(%Account{}, %{ diff --git a/test/bookkeeping/core/audit_log_test.exs b/test/bookkeeping/core/audit_log_test.exs index f86b042..717b0b2 100644 --- a/test/bookkeeping/core/audit_log_test.exs +++ b/test/bookkeeping/core/audit_log_test.exs @@ -3,49 +3,35 @@ defmodule Bookkeeping.Core.AuditLogTest do alias Bookkeeping.Core.AuditLog setup do - details = %{email: "example@example.com"} - {:ok, details: details} - end + params = %{ + record_type: "account", + action_type: "create", + audit_details: %{email: "example@example.com"} + } - test "create a create audit log", %{details: details} do - assert {:ok, create_log} = AuditLog.create("account", "create", details) - assert create_log.record_type == "account" - assert create_log.action_type == "create" - assert create_log.details == details - assert create_log.created_at == create_log.updated_at - assert create_log.deleted_at == nil - assert is_integer(create_log.created_at) - assert is_integer(create_log.updated_at) - assert is_nil(create_log.deleted_at) - end + invalid_params = %{ + record_type: "account", + action_type: "invalid", + audit_details: %{} + } - test "create an update audit log", %{details: details} do - assert {:ok, update_log} = AuditLog.create("account", "update", details) - assert update_log.record_type == "account" - assert update_log.action_type == "update" - assert update_log.details == details - assert update_log.created_at == nil - assert update_log.updated_at != nil - assert update_log.deleted_at == nil - assert is_integer(update_log.updated_at) - assert is_nil(update_log.created_at) - assert is_nil(update_log.deleted_at) + {:ok, params: params, invalid_params: invalid_params} end - test "create a delete audit log", %{details: details} do - assert {:ok, delete_log} = AuditLog.create("account", "delete", details) - assert delete_log.record_type == "account" - assert delete_log.action_type == "delete" - assert delete_log.details == details - assert delete_log.created_at == nil - assert delete_log.updated_at != nil - assert delete_log.deleted_at != nil - assert is_integer(delete_log.updated_at) - assert is_integer(delete_log.deleted_at) - assert is_nil(delete_log.created_at) - end + describe "create/1" do + test "with valid params", %{params: params} do + assert {:ok, audit_log} = AuditLog.create(params) + assert audit_log.record_type == "account" + assert audit_log.action_type == "create" + assert audit_log.details == %{email: "example@example.com"} + end + + test "with invalid field", %{invalid_params: invalid_params} do + assert {:error, :invalid_field} = AuditLog.create(invalid_params) + end - test "create an invalid audit log" do - assert {:error, :invalid_audit_log} = AuditLog.create("account", "invalid", %{}) + test "with invalid params" do + assert {:error, :invalid_params} = AuditLog.create(nil) + end end end From 1ac66f6835934970a63e6c17112ba1e95edda2a7 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 21 Dec 2023 22:30:13 +0800 Subject: [PATCH 25/32] rework line_item core and rename validate_create_params to validate_params --- lib/bookkeeping/core/account.ex | 6 +- lib/bookkeeping/core/audit_log.ex | 6 +- lib/bookkeeping/core/line_item.ex | 389 +++++++++++++++-------- test/bookkeeping/core/account_test.exs | 215 ------------- test/bookkeeping/core/line_item_test.exs | 265 +++++++++------ 5 files changed, 431 insertions(+), 450 deletions(-) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index bd38625..519b0ff 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -248,7 +248,7 @@ defmodule Bookkeeping.Core.Account do """ @spec create(create_params()) :: {:ok, Account.t()} | {:error, :invalid_params | :invalid_field} def create(params) do - params |> validate_create_params() |> maybe_create() + params |> validate_params() |> maybe_create() end @doc """ @@ -312,7 +312,7 @@ defmodule Bookkeeping.Core.Account do else: {:error, :invalid_account} end - defp validate_create_params( + defp validate_params( %{ code: code, name: name, @@ -330,7 +330,7 @@ defmodule Bookkeeping.Core.Account do else: {:error, :invalid_field} end - defp validate_create_params(_params), do: {:error, :invalid_params} + defp validate_params(_params), do: {:error, :invalid_params} defp maybe_create(%{ code: code, diff --git a/lib/bookkeeping/core/audit_log.ex b/lib/bookkeeping/core/audit_log.ex index ae1f321..25396d7 100644 --- a/lib/bookkeeping/core/audit_log.ex +++ b/lib/bookkeeping/core/audit_log.ex @@ -64,10 +64,10 @@ defmodule Bookkeeping.Core.AuditLog do @spec create(create_params()) :: {:ok, AuditLog.t()} | {:error, :invalid_field | :invalid_params} def create(params) do - params |> validate_create_params() |> maybe_create() + params |> validate_params() |> maybe_create() end - defp validate_create_params( + defp validate_params( %{ record_type: record_type, action_type: action_type, @@ -81,7 +81,7 @@ defmodule Bookkeeping.Core.AuditLog do else: {:error, :invalid_field} end - defp validate_create_params(_), do: {:error, :invalid_params} + defp validate_params(_), do: {:error, :invalid_params} defp maybe_create(%{ record_type: record_type, diff --git a/lib/bookkeeping/core/line_item.ex b/lib/bookkeeping/core/line_item.ex index 4a3ec71..f0804f8 100644 --- a/lib/bookkeeping/core/line_item.ex +++ b/lib/bookkeeping/core/line_item.ex @@ -3,8 +3,12 @@ defmodule Bookkeeping.Core.LineItem do Bookkeeping.Core.LineItem is a struct that represents a line item in a journal entry. A line item is a record of a single account and the amount of money that is either debited or credited. """ + alias Bookkeeping.Core.LineItem alias Bookkeeping.Core.{Account, Types} + @typedoc """ + t type is a struct that represents a line item in a journal entry. + """ @type t :: %__MODULE__{ account: Account.t(), amount: Decimal.t(), @@ -12,147 +16,102 @@ defmodule Bookkeeping.Core.LineItem do description: String.t() } - @type t_accounts :: %{ - left: list(account_amount_pair()), - right: list(account_amount_pair()) - } - - @type account_amount_pair :: %{ - account: Account.t(), - amount: Decimal.t(), - description: String.t() - } - - defstruct account: %Account{}, + defstruct account: nil, amount: 0, entry_type: nil, description: "" @doc """ - Creates a list of line item structs. + Creates a new line item struct. Arguments: - - t_accounts: The map of line items. The map must have the following keys: - - left: The list of maps with account, amount, and description field and represents the entry type of debit. - - right: The list of maps with account, amount, and description field and represents the entry type of credit. + - params: The params of the line item. It must contain the following keys: + - account: The account of the line item. + - amount: The amount of the line item. + - entry_type: The entry type of the line item. + - description: The description of the line item. - Returns `{:ok, [%LineItem{}, ...]}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`. + Returns `{:ok, %LineItem{}}` if the line item is valid. Otherwise, returns `{:error, :invalid_account}`, `{:error, :invalid_amount}`, `{:error, :invalid_entry_type}`, `{:error, :invalid_description}`, or `{:error, :invalid_params}`. ## Examples - iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100), description: ""}], right: [%{account: asset_account, amount: Decimal.new(100), description: ""}]}) - {:ok, [%LineItem{...}, %LineItem{...}]} + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: %{}, active: true}) + {:ok, asset_account} - iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100)}], right: []}) - {:error, :unbalanced_line_items} - """ - @spec bulk_create(t_accounts()) :: - {:ok, list(__MODULE__.t())} - | {:error, %{message: :invalid_line_items, errors: list(atom())}} - | {:error, :invalid_line_items} - def bulk_create(%{left: left, right: right} = t_accounts) when left != [] and right != [] do - bulk_create_result = - t_accounts - |> Task.async_stream(fn - {:left, debit_items} -> Task.async_stream(debit_items, &create(&1, :debit)) - {:right, credit_items} -> Task.async_stream(credit_items, &create(&1, :credit)) - end) - |> Enum.reduce( - %{ - debit_balance: Decimal.new(0), - credit_balance: Decimal.new(0), - balanced: false, - created_line_items: [], - errors: [] - }, - &validate_line_items/2 - ) - - case bulk_create_result.created_line_items do - [] -> - {:error, %{message: :invalid_line_items, errors: bulk_create_result.errors}} - - created_line_items -> - cond do - bulk_create_result.errors != [] -> {:error, bulk_create_result.errors} - bulk_create_result.balanced == false -> {:error, :unbalanced_line_items} - true -> {:ok, created_line_items} - end - end - end + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :debit, description: ""}) + {:ok, %LineItem{...}} - def bulk_create(_), do: {:error, :invalid_line_items} + iex> LineItem.create(%{account: nil, amount: Decimal.new(100), entry_type: :debit, description: ""}) + {:error, :invalid_account} - @doc """ - Creates a new line item struct. - - Arguments: - - account_amount_pair: The map with account and amount field. - - atom_entry_type: The atom that represents the entry type of the line item. The atom must be either `:debit` or `:credit`. - - description (optional): The description of the line item. + iex> LineItem.create(%{account: asset_account, amount: 100, entry_type: :debit, description: ""}) + {:error, :invalid_amount} - Returns `{:ok, %LineItem{}}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`, `{:error, :unbalanced_line_items}`, or `{:error, list(:invalid_amount | :invalid_account | :inactive_account)}`. + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :invalid, description: ""}) + {:error, :invalid_entry_type} - ## Examples + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :debit, description: nil}) + {:error, :invalid_description} - iex> LineItem.create(account_amount_pair(), :debit) - {:ok, %LineItem{...}} + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :debit}) + {:error, :invalid_params} """ - @spec create(account_amount_pair(), Types.entry()) :: + @spec create(LineItem.t()) :: {:ok, __MODULE__.t()} - | {:error, :invalid_line_items} - | {:error, :unbalanced_line_items} - | {:error, list(:invalid_amount | :invalid_account | :inactive_account)} - def create(account_amount_pair, atom_entry_type) do - with {:ok, %{account: account, amount: amount}} <- - validate_account_and_amount(account_amount_pair), - {:ok, entry_type} <- validate_entry_type(atom_entry_type) do - description = Map.get(account_amount_pair, :description, "") - - {:ok, - %__MODULE__{ - account: account, - amount: amount, - entry_type: entry_type, - description: description - }} - else - {:error, message} -> {:error, message} - _ -> {:error, :invalid_line_items} - end + | {:error, + :invalid_account + | :invalid_amount + | :invalid_entry_type + | :invalid_description + | :invalid_params} + def create(params) do + params |> validate_params() |> maybe_create() end - defp validate_line_items({:ok, line_items}, acc) do - Enum.reduce(line_items, acc, fn - {:ok, {:ok, line_item}}, acc -> process_line_item(acc, line_item) - {:ok, {:error, message}}, acc -> Map.put(acc, :errors, [message | acc.errors]) - end) + def validate(line_item) + when is_struct(line_item, LineItem) do + case validate_params(line_item) do + {:error, _reason} -> {:error, :invalid_line_item} + _params -> {:ok, line_item} + end end - defp validate_account_and_amount(account_amount_pair) when is_map(account_amount_pair) do - account = Map.get(account_amount_pair, :account) - amount = Map.get(account_amount_pair, :amount) - - with {:ok, account} <- validate_account(account), - {:ok, amount} <- validate_amount(amount) do - {:ok, %{account: account, amount: amount}} - else - {:error, message} -> {:error, message} - _ -> {:error, :invalid_line_items} + def validate(_), do: {:error, :invalid_line_item} + + defp validate_params( + %{ + account: account, + amount: amount, + entry_type: entry_type, + description: description + } = params + ) do + with {:ok, _account} <- Account.validate(account), + {:ok, _amount} <- validate_amount(amount), + {:ok, _entry_type} <- validate_entry_type(entry_type), + {:ok, _description} <- validate_description(description) do + params end end - defp validate_account_and_amount(_), do: {:error, :invalid_account_and_amount_map} - - defp validate_account(account) - when is_struct(account, Account) and not account.active, - do: {:error, :inactive_account} + defp validate_params(_), do: {:error, :invalid_params} - defp validate_account(account) - when is_struct(account, Account) and account.active, - do: {:ok, account} + defp maybe_create(%{ + account: account, + amount: amount, + entry_type: entry_type, + description: description + }) do + {:ok, + %__MODULE__{ + account: account, + amount: amount, + entry_type: entry_type, + description: description + }} + end - defp validate_account(_), do: {:error, :invalid_account} + defp maybe_create({:error, reason}), do: {:error, reason} defp validate_amount(amount) when is_struct(amount, Decimal) do if Decimal.gt?(amount, Decimal.new(0)), @@ -162,28 +121,184 @@ defmodule Bookkeeping.Core.LineItem do defp validate_amount(_), do: {:error, :invalid_amount} - defp validate_entry_type(:debit), do: {:ok, :debit} - defp validate_entry_type(:credit), do: {:ok, :credit} - - defp process_line_item(acc, line_item) do - entry_type = line_item.entry_type - - updated_debit_balance = - if entry_type == :debit, - do: Decimal.add(acc.debit_balance, line_item.amount), - else: acc.debit_balance - - updated_credit_balance = - if entry_type == :credit, - do: Decimal.add(acc.credit_balance, line_item.amount), - else: acc.credit_balance - - %{ - debit_balance: updated_debit_balance, - credit_balance: updated_credit_balance, - balanced: Decimal.equal?(updated_debit_balance, updated_credit_balance), - created_line_items: [line_item | acc.created_line_items], - errors: acc.errors - } - end + defp validate_entry_type(type) when type in [:debit, :credit], do: {:ok, type} + defp validate_entry_type(_), do: {:error, :invalid_entry_type} + + defp validate_description(description) when is_binary(description), do: {:ok, description} + defp validate_description(_), do: {:error, :invalid_description} + # @typedoc """ + # t_accounts type is a map that represents the debit and credit lists of line amount data. + # """ + # @type t_accounts :: %{ + # left: list(line_amount_data()), + # right: list(line_amount_data()) + # } + + # @typedoc """ + # line_amount_data type is a map that represents the account, amount, and description of a line item. + # """ + # @type line_amount_data :: %{ + # account: Account.t(), + # amount: Decimal.t(), + # description: String.t() + # } + + # @doc """ + # Creates a list of line item structs. + + # Arguments: + # - t_accounts: The map of line items. The map must have the following keys: + # - left: The list of maps with account, amount, and description field and represents the entry type of debit. + # - right: The list of maps with account, amount, and description field and represents the entry type of credit. + + # Returns `{:ok, [%LineItem{}, ...]}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`. + + # ## Examples + + # iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100), description: ""}], right: [%{account: asset_account, amount: Decimal.new(100), description: ""}]}) + # {:ok, [%LineItem{...}, %LineItem{...}]} + + # iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100)}], right: []}) + # {:error, :unbalanced_line_items} + # """ + # @spec bulk_create(t_accounts()) :: + # {:ok, list(__MODULE__.t())} + # | {:error, %{message: :invalid_line_items, errors: list(atom())}} + # | {:error, :invalid_line_items} + # def bulk_create(%{left: left, right: right} = t_accounts) when left != [] and right != [] do + # bulk_create_result = + # t_accounts + # |> Task.async_stream(fn + # {:left, debit_items} -> Task.async_stream(debit_items, &create(&1, :debit)) + # {:right, credit_items} -> Task.async_stream(credit_items, &create(&1, :credit)) + # end) + # |> Enum.reduce( + # %{ + # debit_balance: Decimal.new(0), + # credit_balance: Decimal.new(0), + # balanced: false, + # created_line_items: [], + # errors: [] + # }, + # &validate_line_items/2 + # ) + + # case bulk_create_result.created_line_items do + # [] -> + # {:error, %{message: :invalid_line_items, errors: bulk_create_result.errors}} + + # created_line_items -> + # cond do + # bulk_create_result.errors != [] -> {:error, bulk_create_result.errors} + # bulk_create_result.balanced == false -> {:error, :unbalanced_line_items} + # true -> {:ok, created_line_items} + # end + # end + # end + + # def bulk_create(_), do: {:error, :invalid_line_items} + + # @doc """ + # Creates a new line item struct. + + # Arguments: + # - account_amount_pair: The map with account and amount field. + # - atom_entry_type: The atom that represents the entry type of the line item. The atom must be either `:debit` or `:credit`. + # - description (optional): The description of the line item. + + # Returns `{:ok, %LineItem{}}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`, `{:error, :unbalanced_line_items}`, or `{:error, list(:invalid_amount | :invalid_account | :inactive_account)}`. + + # ## Examples + + # iex> LineItem.create(line_amount_data(), :debit) + # {:ok, %LineItem{...}} + # """ + # @spec create(line_amount_data(), Types.entry()) :: + # {:ok, __MODULE__.t()} + # | {:error, :invalid_line_items} + # | {:error, :unbalanced_line_items} + # | {:error, list(:invalid_amount | :invalid_account | :inactive_account)} + # def create(account_amount_pair, atom_entry_type) do + # with {:ok, %{account: account, amount: amount}} <- + # validate_account_and_amount(account_amount_pair), + # {:ok, entry_type} <- validate_entry_type(atom_entry_type) do + # description = Map.get(account_amount_pair, :description, "") + + # {:ok, + # %__MODULE__{ + # account: account, + # amount: amount, + # entry_type: entry_type, + # description: description + # }} + # else + # {:error, message} -> {:error, message} + # _ -> {:error, :invalid_line_items} + # end + # end + + # defp validate_line_items({:ok, line_items}, acc) do + # Enum.reduce(line_items, acc, fn + # {:ok, {:ok, line_item}}, acc -> process_line_item(acc, line_item) + # {:ok, {:error, message}}, acc -> Map.put(acc, :errors, [message | acc.errors]) + # end) + # end + + # defp validate_account_and_amount(account_amount_pair) when is_map(account_amount_pair) do + # account = Map.get(account_amount_pair, :account) + # amount = Map.get(account_amount_pair, :amount) + + # with {:ok, account} <- validate_account(account), + # {:ok, amount} <- validate_amount(amount) do + # {:ok, %{account: account, amount: amount}} + # else + # {:error, message} -> {:error, message} + # _ -> {:error, :invalid_line_items} + # end + # end + + # defp validate_account_and_amount(_), do: {:error, :invalid_account_and_amount_map} + + # defp validate_account(account) + # when is_struct(account, Account) and not account.active, + # do: {:error, :inactive_account} + + # defp validate_account(account) + # when is_struct(account, Account) and account.active, + # do: {:ok, account} + + # defp validate_account(_), do: {:error, :invalid_account} + + # defp validate_amount(amount) when is_struct(amount, Decimal) do + # if Decimal.gt?(amount, Decimal.new(0)), + # do: {:ok, amount}, + # else: {:error, :invalid_amount} + # end + + # defp validate_amount(_), do: {:error, :invalid_amount} + + # defp validate_entry_type(:debit), do: {:ok, :debit} + # defp validate_entry_type(:credit), do: {:ok, :credit} + + # defp process_line_item(acc, line_item) do + # entry_type = line_item.entry_type + + # updated_debit_balance = + # if entry_type == :debit, + # do: Decimal.add(acc.debit_balance, line_item.amount), + # else: acc.debit_balance + + # updated_credit_balance = + # if entry_type == :credit, + # do: Decimal.add(acc.credit_balance, line_item.amount), + # else: acc.credit_balance + + # %{ + # debit_balance: updated_debit_balance, + # credit_balance: updated_credit_balance, + # balanced: Decimal.equal?(updated_debit_balance, updated_credit_balance), + # created_line_items: [line_item | acc.created_line_items], + # errors: acc.errors + # } + # end end diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index 023e421..d4cc378 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -106,221 +106,6 @@ defmodule Bookkeeping.Core.AccountTest do assert is_boolean(account.active) assert is_list(account.audit_logs) assert is_struct(account.classification, Bookkeeping.Core.Account.Classification) - - # assert {:ok, _liability} = - # Account.create(%{ - # code: "20_000", - # name: "liability", - # classification: "liability", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _equity} = - # Account.create(%{ - # code: "30_000", - # name: "equity", - # classification: "equity", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _revenue} = - # Account.create(%{ - # code: "40_000", - # name: "revenue", - # classification: "revenue", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _expense} = - # Account.create(%{ - # code: "50_000", - # name: "expense", - # classification: "expense", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _gain} = - # Account.create(%{ - # code: "50_000", - # name: "gain", - # classification: "gain", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _loss} = - # Account.create(%{ - # code: "50_000", - # name: "loss", - # classification: "loss", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _contra_asset} = - # Account.create(%{ - # code: "60_000", - # name: "contra_asset", - # classification: "contra_asset", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _contra_liability} = - # Account.create(%{ - # code: "70_000", - # name: "contra_liability", - # classification: "contra_liability", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _contra_equity} = - # Account.create(%{ - # code: "80_000", - # name: "contra_equity", - # classification: "contra_equity", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _contra_revenue} = - # Account.create(%{ - # code: "90_000", - # name: "contra_revenue", - # classification: "contra_revenue", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _contra_expense} = - # Account.create(%{ - # code: "100_000", - # name: "contra_expense", - # classification: "contra_expense", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _contra_gain} = - # Account.create(%{ - # code: "100_000", - # name: "contra_gain", - # classification: "contra_gain", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:ok, _contra_loss} = - # Account.create(%{ - # code: "100_000", - # name: "contra_loss", - # classification: "contra_loss", - # description: "description", - # audit_details: details, - # active: true - # }) - # end - - # test "create/1 with invalid params", %{details: details} do - # assert {:error, :invalid_params} = Account.create(%{}) - # assert {:error, :invalid_params} = Account.create(nil) - # assert {:error, :invalid_params} = Account.create("apple") - - # assert {:error, :invalid_params} = - # Account.create(%{ - # code: "10_000", - # name: "cash", - # classification: "asset", - # description: "description" - # }) - - # assert {:error, :invalid_params} = - # Account.create(%{ - # code: "10_000", - # name: "cash", - # classification: "asset", - # description: "description", - # audit_details: details - # }) - # end - - # test "with invalid field", %{details: details} do - # assert {:error, :invalid_field} = - # Account.create(%{ - # code: 10_000, - # name: "cash", - # classification: "asset", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:error, :invalid_field} = - # Account.create(%{ - # code: "10_000", - # name: nil, - # classification: "asset", - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:error, :invalid_field} = - # Account.create(%{ - # code: "10_000", - # name: "cash", - # classification: nil, - # description: "description", - # audit_details: details, - # active: true - # }) - - # assert {:error, :invalid_field} = - # Account.create(%{ - # code: "10_000", - # name: "cash", - # classification: "asset", - # description: nil, - # audit_details: details, - # active: true - # }) - - # assert {:error, :invalid_field} = - # Account.create(%{ - # code: "10_000", - # name: "cash", - # classification: "asset", - # description: "description", - # audit_details: nil, - # active: true - # }) - - # assert {:error, :invalid_field} = - # Account.create(%{ - # code: "10_000", - # name: "cash", - # classification: "asset", - # description: "description", - # audit_details: details, - # active: nil - # }) end end diff --git a/test/bookkeeping/core/line_item_test.exs b/test/bookkeeping/core/line_item_test.exs index c07d930..afc38c1 100644 --- a/test/bookkeeping/core/line_item_test.exs +++ b/test/bookkeeping/core/line_item_test.exs @@ -3,105 +3,186 @@ defmodule Bookkeeping.Core.LineItemTest do alias Bookkeeping.Core.{Account, LineItem} setup do - details = %{email: "example@example.com"} - {:ok, details: details} + account_params = %{ + code: "1000", + name: "Cash", + description: "Cash and cash equivalents", + classification: "asset", + audit_details: %{email: "example@example.com"}, + active: true + } + + {:ok, account} = Account.create(account_params) + + params = %{ + account: account, + amount: Decimal.new(100), + entry_type: :debit, + description: "line description" + } + + {:ok, account: account, params: params} end - test "bulk create line items", %{details: details} do - assert {:ok, asset_account} = - Account.create("10000", "cash", "asset", "description", details) - - assert {:ok, expense_account} = - Account.create("20000", "rent", "expense", "description", details) - - assert {:ok, bulk_create_result} = - LineItem.bulk_create(%{ - left: [ - %{ - account: expense_account, - amount: Decimal.new(100), - description: "rent expense" - } - ], - right: [ - %{ - account: asset_account, - amount: Decimal.new(100), - description: "cash paid for rent" - } - ] - }) - - refute bulk_create_result == [] - - assert {:error, %{message: :invalid_line_items, errors: [:invalid_account, :invalid_account]}} = - LineItem.bulk_create(%{ - left: [%{account: "expense_account", amount: Decimal.new(100)}], - right: [%{account: "asset_account", amount: Decimal.new(100)}] - }) - - assert {:error, :invalid_line_items} = LineItem.bulk_create(%{}) - - assert {:error, [:invalid_account]} = - LineItem.bulk_create(%{ - left: [%{account: expense_account, amount: Decimal.new(100)}], - right: [%{account: "asset_account", amount: Decimal.new(100)}] - }) - - assert {:error, :invalid_line_items} = - LineItem.bulk_create(%{ - left: [%{account: expense_account, amount: Decimal.new(100)}], - right: [] - }) - - assert {:error, :unbalanced_line_items} = - LineItem.bulk_create(%{ - left: [%{account: expense_account, amount: Decimal.new(100)}], - right: [%{account: asset_account, amount: Decimal.new(200)}] - }) - - assert {:error, [:invalid_account]} = - LineItem.bulk_create(%{ - left: [%{account: expense_account, amount: Decimal.new(100)}], - right: [%{account: asset_account, amount: Decimal.new(100)}, %{}] - }) - - assert {:ok, expense_account_2} = - Account.create("20020", "depreciation", "expense", "description", details) - - assert {:ok, updated_expense_account_2} = - Account.update(expense_account_2, %{name: "depreciation expense", active: false}) - - assert {:error, [:inactive_account]} = - LineItem.bulk_create(%{ - left: [%{account: updated_expense_account_2, amount: Decimal.new(100)}], - right: [%{account: asset_account, amount: Decimal.new(100)}] - }) + describe "create/1" do + test "with valid params", %{params: params} do + assert {:ok, line_item} = LineItem.create(params) + + assert line_item.account == params.account + assert line_item.amount == params.amount + assert line_item.entry_type == params.entry_type + assert line_item.description == params.description + end + + test "with invalid account", %{params: params} do + params = Map.put(params, :account, %{}) + assert {:error, :invalid_account} = LineItem.create(params) + end + + test "with invalid amount", %{params: params} do + params = Map.put(params, :amount, 100) + assert {:error, :invalid_amount} = LineItem.create(params) + end + + test "with invalid entry type", %{params: params} do + params = Map.put(params, :entry_type, "invalid") + assert {:error, :invalid_entry_type} = LineItem.create(params) + end + + test "with invalid description", %{params: params} do + params = Map.put(params, :description, nil) + assert {:error, :invalid_description} = LineItem.create(params) + end + + test "with invalid params" do + assert {:error, :invalid_params} = LineItem.create(%{}) + assert {:error, :invalid_params} = LineItem.create(nil) + assert {:error, :invalid_params} = LineItem.create("") + end end - test "create line item with valid account, amount, and binary_entry_type", %{details: details} do - assert {:ok, asset_account} = Account.create("10000", "cash", "asset", "description", details) - - assert {:ok, line_item} = - LineItem.create(%{account: asset_account, amount: Decimal.new(100)}, :debit) - - assert line_item.account == asset_account - assert line_item.amount == Decimal.new(100) - assert line_item.entry_type == :debit - assert line_item.description == "" + describe "validate/1" do + test "with valid line_item", %{params: params} do + assert {:ok, line_item} = LineItem.create(params) + assert {:ok, line_item} = LineItem.validate(line_item) + end + + test "with invalid line_item", %{params: params} do + assert {:error, :invalid_line_item} = LineItem.validate(%{}) + assert {:error, :invalid_line_item} = LineItem.validate(nil) + assert {:error, :invalid_line_item} = LineItem.validate("") + assert {:error, :invalid_line_item} = LineItem.validate(params) + assert {:ok, line_item} = LineItem.create(params) + modified_line_item = Map.put(line_item, :description, nil) + assert {:error, :invalid_line_item} = LineItem.validate(modified_line_item) + end end - test "disallow line item with invalid fields" do - assert {:error, :invalid_account} = - LineItem.create(%{account: "asset", amount: Decimal.new(100)}, "invalid") - - assert {:error, :invalid_account_and_amount_map} = LineItem.create(nil, "invalid") + defp update_params(params) do + code = random_string() + name = random_string() + Map.merge(params, %{code: code, name: name}) end - test "disallow line item with invalid amount", %{details: details} do - {:ok, asset_account} = Account.create("10000", "cash", "asset", "description", details) - - assert {:error, :invalid_amount} = - LineItem.create(%{account: asset_account, amount: 100}, :debit) + defp random_string do + for _ <- 1..10, into: "", do: <> end + + # test "bulk create line items", %{details: details} do + # assert {:ok, asset_account} = + # Account.create("10000", "cash", "asset", "description", details) + + # assert {:ok, expense_account} = + # Account.create("20000", "rent", "expense", "description", details) + + # assert {:ok, bulk_create_result} = + # LineItem.bulk_create(%{ + # left: [ + # %{ + # account: expense_account, + # amount: Decimal.new(100), + # description: "rent expense" + # } + # ], + # right: [ + # %{ + # account: asset_account, + # amount: Decimal.new(100), + # description: "cash paid for rent" + # } + # ] + # }) + + # refute bulk_create_result == [] + + # assert {:error, %{message: :invalid_line_items, errors: [:invalid_account, :invalid_account]}} = + # LineItem.bulk_create(%{ + # left: [%{account: "expense_account", amount: Decimal.new(100)}], + # right: [%{account: "asset_account", amount: Decimal.new(100)}] + # }) + + # assert {:error, :invalid_line_items} = LineItem.bulk_create(%{}) + + # assert {:error, [:invalid_account]} = + # LineItem.bulk_create(%{ + # left: [%{account: expense_account, amount: Decimal.new(100)}], + # right: [%{account: "asset_account", amount: Decimal.new(100)}] + # }) + + # assert {:error, :invalid_line_items} = + # LineItem.bulk_create(%{ + # left: [%{account: expense_account, amount: Decimal.new(100)}], + # right: [] + # }) + + # assert {:error, :unbalanced_line_items} = + # LineItem.bulk_create(%{ + # left: [%{account: expense_account, amount: Decimal.new(100)}], + # right: [%{account: asset_account, amount: Decimal.new(200)}] + # }) + + # assert {:error, [:invalid_account]} = + # LineItem.bulk_create(%{ + # left: [%{account: expense_account, amount: Decimal.new(100)}], + # right: [%{account: asset_account, amount: Decimal.new(100)}, %{}] + # }) + + # assert {:ok, expense_account_2} = + # Account.create("20020", "depreciation", "expense", "description", details) + + # assert {:ok, updated_expense_account_2} = + # Account.update(expense_account_2, %{name: "depreciation expense", active: false}) + + # assert {:error, [:inactive_account]} = + # LineItem.bulk_create(%{ + # left: [%{account: updated_expense_account_2, amount: Decimal.new(100)}], + # right: [%{account: asset_account, amount: Decimal.new(100)}] + # }) + # end + + # test "create line item with valid account, amount, and binary_entry_type", %{details: details} do + # assert {:ok, asset_account} = Account.create("10000", "cash", "asset", "description", details) + + # assert {:ok, line_item} = + # LineItem.create(%{account: asset_account, amount: Decimal.new(100)}, :debit) + + # assert line_item.account == asset_account + # assert line_item.amount == Decimal.new(100) + # assert line_item.entry_type == :debit + # assert line_item.description == "" + # end + + # test "disallow line item with invalid fields" do + # assert {:error, :invalid_account} = + # LineItem.create(%{account: "asset", amount: Decimal.new(100)}, "invalid") + + # assert {:error, :invalid_account_and_amount_map} = LineItem.create(nil, "invalid") + # end + + # test "disallow line item with invalid amount", %{details: details} do + # {:ok, asset_account} = Account.create("10000", "cash", "asset", "description", details) + + # assert {:error, :invalid_amount} = + # LineItem.create(%{account: asset_account, amount: 100}, :debit) + # end end From 4d58a352dfea70c0e9479da7f38c00a0491200f4 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 21 Dec 2023 22:48:46 +0800 Subject: [PATCH 26/32] rename attributes --- lib/bookkeeping.ex | 4 ++-- lib/bookkeeping/boundary/accounting_journal/server.ex | 4 ++-- lib/bookkeeping/boundary/chart_of_accounts/worker.ex | 6 +++--- lib/bookkeeping/data/sample_chart_of_accounts.csv | 2 +- lib/bookkeeping/data/sample_journal_entries.csv | 2 +- test/bookkeeping/data/duplicate_chart_of_accounts.csv | 2 +- test/bookkeeping/data/duplicate_journal_entries.csv | 2 +- test/bookkeeping/data/empty_chart_of_accounts.csv | 2 +- test/bookkeeping/data/empty_chart_of_accounts_2.csv | 2 +- test/bookkeeping/data/empty_journal_entries.csv | 2 +- test/bookkeeping/data/invalid_chart_of_accounts.csv | 2 +- test/bookkeeping/data/invalid_journal_entries.csv | 2 +- test/bookkeeping/data/partially_valid_chart_of_accounts.csv | 2 +- test/bookkeeping/data/partially_valid_journal_entries.csv | 2 +- test/bookkeeping/data/valid_bookkeeping_accounts.csv | 2 +- test/bookkeeping/data/valid_bookkeeping_journal_entries.csv | 2 +- test/bookkeeping/data/valid_chart_of_accounts.csv | 2 +- test/bookkeeping/data/valid_chart_of_accounts_2.csv | 2 +- test/bookkeeping/data/valid_journal_entries.csv | 2 +- 19 files changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/bookkeeping.ex b/lib/bookkeeping.ex index 5bb38d4..a7df1c2 100644 --- a/lib/bookkeeping.ex +++ b/lib/bookkeeping.ex @@ -49,7 +49,7 @@ defmodule Bookkeeping do @doc """ Imports accounts from a CSV file. - The header of the CSV file must be `Account Code`, `Account Name`, `Classification`, `Account Description`, and `Audit Details`. + The header of the CSV file must be `Code`, `Name`, `Classification`, `Description`, and `Audit Details`. Arguments: - path: The path of the CSV file. @@ -250,7 +250,7 @@ defmodule Bookkeeping do # @doc """ # Imports journal entries from a CSV file. - # The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Account Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` + # The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` # Arguments: # - path: The path of the CSV file. diff --git a/lib/bookkeeping/boundary/accounting_journal/server.ex b/lib/bookkeeping/boundary/accounting_journal/server.ex index 61dbb33..34a281d 100644 --- a/lib/bookkeeping/boundary/accounting_journal/server.ex +++ b/lib/bookkeeping/boundary/accounting_journal/server.ex @@ -164,7 +164,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do @doc """ Imports journal entries from a CSV file. - The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Account Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` + The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` Arguments: - path: The path of the CSV file. @@ -812,7 +812,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do initial_params, journal_entry_number ) do - account = Map.get(csv_item, "Account Name", "") + account = Map.get(csv_item, "Name", "") description = Map.get(csv_item, "Line Item Description", "") debit = Map.get(csv_item, "Debit", "") credit = Map.get(csv_item, "Credit", "") diff --git a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex index 71684fa..865676b 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -220,10 +220,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do defp bulk_generate_params({:ok, csv}) do Enum.reduce(csv, [], fn csv_item, acc -> - code = Map.get(csv_item, "Account Code") - name = Map.get(csv_item, "Account Name") + code = Map.get(csv_item, "Code") + name = Map.get(csv_item, "Name") classification = Map.get(csv_item, "Classification") - description = Map.get(csv_item, "Account Description", "") + description = Map.get(csv_item, "Description", "") audit_details = case csv_item |> Map.get("Audit Details", "{}") |> Jason.decode() do diff --git a/lib/bookkeeping/data/sample_chart_of_accounts.csv b/lib/bookkeeping/data/sample_chart_of_accounts.csv index 8804900..d1efcff 100644 --- a/lib/bookkeeping/data/sample_chart_of_accounts.csv +++ b/lib/bookkeeping/data/sample_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details 101,Cash,asset,Cash,"{""approved_by"": ""example@example.com""}" 102,Accounts Receivable,asset,Accounts Receivable,"{""approved_by"": ""example@example.com""}" 103,Inventory,asset,Inventory,"{""approved_by"": ""example@example.com""}" diff --git a/lib/bookkeeping/data/sample_journal_entries.csv b/lib/bookkeeping/data/sample_journal_entries.csv index 8c86a70..c3e4441 100644 --- a/lib/bookkeeping/data/sample_journal_entries.csv +++ b/lib/bookkeeping/data/sample_journal_entries.csv @@ -1,4 +1,4 @@ -Journal Entry Number,Transaction Date,Account Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number +Journal Entry Number,Transaction Date,Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number 1000,08-01-2023,Cash,1000000,,yes,,{},"{""created_by"": ""example@example.com""}",08-01-2023, 1000,08-01-2023,Common Stock,,1000000,yes,,{},"{""created_by"": ""example@example.com""}",08-01-2023, 1001,08-05-2023,Inventory,5000,,no,,{},"{""created_by"": ""example@example.com""}",08-05-2023, diff --git a/test/bookkeeping/data/duplicate_chart_of_accounts.csv b/test/bookkeeping/data/duplicate_chart_of_accounts.csv index a3890ab..d0bff0a 100644 --- a/test/bookkeeping/data/duplicate_chart_of_accounts.csv +++ b/test/bookkeeping/data/duplicate_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details 100000101,Accounts Receivable Bulk Duplicate Test,asset,Accounts Receivable,{} 100000001,Cash Bulk Duplicate Test,asset,Cash,"{""approved_by"": ""example@example.com""}" 100000001,Cash Bulk Duplicate Test,asset,Cash,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/data/duplicate_journal_entries.csv b/test/bookkeeping/data/duplicate_journal_entries.csv index b0c7f25..4f5494c 100644 --- a/test/bookkeeping/data/duplicate_journal_entries.csv +++ b/test/bookkeeping/data/duplicate_journal_entries.csv @@ -1,4 +1,4 @@ -Journal Entry Number,Transaction Date,Account Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number +Journal Entry Number,Transaction Date,Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number 1004,08-12-2023,"Property, Plant, and Equipment",20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, ,08-12-2023,,20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, 1004,08-12-2023,Accounts Payable,,5000,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, diff --git a/test/bookkeeping/data/empty_chart_of_accounts.csv b/test/bookkeeping/data/empty_chart_of_accounts.csv index 732ee4b..c99e5ca 100644 --- a/test/bookkeeping/data/empty_chart_of_accounts.csv +++ b/test/bookkeeping/data/empty_chart_of_accounts.csv @@ -1 +1 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details diff --git a/test/bookkeeping/data/empty_chart_of_accounts_2.csv b/test/bookkeeping/data/empty_chart_of_accounts_2.csv index 053394d..3b5f8df 100644 --- a/test/bookkeeping/data/empty_chart_of_accounts_2.csv +++ b/test/bookkeeping/data/empty_chart_of_accounts_2.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details diff --git a/test/bookkeeping/data/empty_journal_entries.csv b/test/bookkeeping/data/empty_journal_entries.csv index d9ae026..6e160f8 100644 --- a/test/bookkeeping/data/empty_journal_entries.csv +++ b/test/bookkeeping/data/empty_journal_entries.csv @@ -1 +1 @@ -Journal Entry Number,Transaction Date,Account Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number +Journal Entry Number,Transaction Date,Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number diff --git a/test/bookkeeping/data/invalid_chart_of_accounts.csv b/test/bookkeeping/data/invalid_chart_of_accounts.csv index 8e48a5d..2e33791 100644 --- a/test/bookkeeping/data/invalid_chart_of_accounts.csv +++ b/test/bookkeeping/data/invalid_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details ,,asset,Cash,"{""approved_by"": ""example@example.com""}" ,, 1000000020,Accounts Receivable Bulk Test,asset,Accounts Receivable,100 diff --git a/test/bookkeeping/data/invalid_journal_entries.csv b/test/bookkeeping/data/invalid_journal_entries.csv index d6e81af..216b20c 100644 --- a/test/bookkeeping/data/invalid_journal_entries.csv +++ b/test/bookkeeping/data/invalid_journal_entries.csv @@ -1,4 +1,4 @@ -Journal Entry Number,Transaction Date,Account Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number +Journal Entry Number,Transaction Date,Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number 1002,08-12-2023,"Property, Plant, and Equipment",20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, 1002,08-12-2023,Inventory,10000,,,,{},"{""created_by"": ""example@example.com""}",08-12-2023, 1002,08-12-2023,Accounts Payable,,5000,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, diff --git a/test/bookkeeping/data/partially_valid_chart_of_accounts.csv b/test/bookkeeping/data/partially_valid_chart_of_accounts.csv index 3af8c68..23dd70b 100644 --- a/test/bookkeeping/data/partially_valid_chart_of_accounts.csv +++ b/test/bookkeeping/data/partially_valid_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details 1000001012,Accounts Receivable Bulk Test 2,asset,Accounts Receivable,{} 1000001012,Accounts Receivable Bulk Test 2,asset,Accounts Receivable,{} 1000000012,Cash Bulk Test 2,asset,Cash,"test" diff --git a/test/bookkeeping/data/partially_valid_journal_entries.csv b/test/bookkeeping/data/partially_valid_journal_entries.csv index 9659fc1..035566c 100644 --- a/test/bookkeeping/data/partially_valid_journal_entries.csv +++ b/test/bookkeeping/data/partially_valid_journal_entries.csv @@ -1,4 +1,4 @@ -Journal Entry Number,Transaction Date,Account Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number +Journal Entry Number,Transaction Date,Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number 1008,08-12-2023,"Property, Plant, and Equipment",20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, 1008,08-12-2023,Inventory,20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, 1008,08-12-2023,Accounts Payable,,5000,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, diff --git a/test/bookkeeping/data/valid_bookkeeping_accounts.csv b/test/bookkeeping/data/valid_bookkeeping_accounts.csv index c2fe84a..8119293 100644 --- a/test/bookkeeping/data/valid_bookkeeping_accounts.csv +++ b/test/bookkeeping/data/valid_bookkeeping_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details 1001_bookkeeping_test,Accounts Receivable Bookkeeping Test,asset,Accounts Receivable,{} 1002_bookkeeping_test,Cash Bookkeeping Test,asset,Cash,"{""approved_by"": ""example@example.com""}" 101_bookkeeping_test,Cash Bookkeeping Test,asset,Cash,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/data/valid_bookkeeping_journal_entries.csv b/test/bookkeeping/data/valid_bookkeeping_journal_entries.csv index f3a364b..44078ca 100644 --- a/test/bookkeeping/data/valid_bookkeeping_journal_entries.csv +++ b/test/bookkeeping/data/valid_bookkeeping_journal_entries.csv @@ -1,4 +1,4 @@ -Journal Entry Number,Transaction Date,Account Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number +Journal Entry Number,Transaction Date,Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number 1001_Bookkeeping_Test,08-12-2023,"Property, Plant, and Equipment Bookkeeping Test",20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, 1001_Bookkeeping_Test,08-12-2023,Inventory Bookkeeping Test,20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, 1001_Bookkeeping_Test,08-12-2023,Accounts Payable Bookkeeping Test,,5000,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023, diff --git a/test/bookkeeping/data/valid_chart_of_accounts.csv b/test/bookkeeping/data/valid_chart_of_accounts.csv index 47b2932..f4868bb 100644 --- a/test/bookkeeping/data/valid_chart_of_accounts.csv +++ b/test/bookkeeping/data/valid_chart_of_accounts.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details 10000010,Accounts Receivable Bulk Test,asset,Accounts Receivable,{} 10000000,Cash Bulk Test,asset,Cash,"{""approved_by"": ""example@example.com""}" 101,Cash,asset,Cash,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/data/valid_chart_of_accounts_2.csv b/test/bookkeeping/data/valid_chart_of_accounts_2.csv index f739573..3e3ca2c 100644 --- a/test/bookkeeping/data/valid_chart_of_accounts_2.csv +++ b/test/bookkeeping/data/valid_chart_of_accounts_2.csv @@ -1,4 +1,4 @@ -Account Code,Account Name,Classification,Account Description,Audit Details +Code,Name,Classification,Description,Audit Details 1013,Cash,asset,Cash,"{""approved_by"": ""example@example.com""}" 100000101,Accounts Receivable Bulk Test,asset,Accounts Receivable,{} 1034,Inventory,asset,Inventory,"{""approved_by"": ""example@example.com""}" diff --git a/test/bookkeeping/data/valid_journal_entries.csv b/test/bookkeeping/data/valid_journal_entries.csv index 8a3eb5f..ca42561 100644 --- a/test/bookkeeping/data/valid_journal_entries.csv +++ b/test/bookkeeping/data/valid_journal_entries.csv @@ -1,4 +1,4 @@ -Journal Entry Number,Transaction Date,Account Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number,Line Item Description +Journal Entry Number,Transaction Date,Name,Debit,Credit,Posted,Journal Entry Description,Journal Entry Details,Audit Details,General Ledger Posting Date,Transaction Reference Number,Line Item Description 1001,08-12-2023,"Property, Plant, and Equipment",20000,,no,,{},"{""created_by"": ""example@example.com""}",08-12-2023,,Bought a new property 1001,08-12-2023,Inventory,20000,,no,JE_1001_INV,{},"{""created_by"": ""example@example.com""}",08-12-2023,,Bought additional inventory from AAA Company 1001,08-12-2023,Accounts Payable,,5000,no,JE_1001_AP,{},"{""created_by"": ""example@example.com""}",08-12-2023,,Remaining Payable amount From b3cb99d0a6d6db6ca398f26e819d8b7d9a3145d7 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Fri, 22 Dec 2023 00:20:12 +0800 Subject: [PATCH 27/32] remove bookkeeping journal entry --- lib/bookkeeping.ex | 49 +- lib/bookkeeping/application.ex | 6 +- .../boundary/accounting_journal/server.ex | 1694 ++++++++--------- lib/bookkeeping/core/journal_entry.ex | 123 +- .../boundary/accounting_journal_test.exs | 136 +- test/bookkeeping/core/journal_entry_test.exs | 132 +- 6 files changed, 1067 insertions(+), 1073 deletions(-) diff --git a/lib/bookkeeping.ex b/lib/bookkeeping.ex index a7df1c2..27a137b 100644 --- a/lib/bookkeeping.ex +++ b/lib/bookkeeping.ex @@ -10,9 +10,8 @@ defmodule Bookkeeping do 4. Run `Bookkeeping.import_journal_entries("../../data/sample_journal_entries.csv")` to import sample journal entries. """ - alias Bookkeeping.Boundary.AccountingJournal.Server, as: AccountingJournal alias Bookkeeping.Boundary.ChartOfAccounts.Worker, as: ChartOfAccounts - alias Bookkeeping.Core.{Account, JournalEntry} + alias Bookkeeping.Core.Account ########################################################## # Chart of Accounts Functions # @@ -205,14 +204,14 @@ defmodule Bookkeeping do # Arguments: # - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) - # - general_ledger_posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. + # - posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. # - t_accounts: The map of line items. The map must have the following keys: # - left: The list of maps with account and amount field and represents the entry type of debit. # - right: The list of maps with account and amount field and represents the entry type of credit. - # - journal_entry_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). - # - transaction_reference_number (optional): The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) - # - journal_entry_description (optional): The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) - # - journal_entry_details (optional): The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) + # - document_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). + # - reference_number (optional): The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) + # - description (optional): The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) + # - particulars (optional): The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) # - audit_details (optional): The details of the audit log. # Returns `{:ok, JournalEntry.t()}` if the journal entry is created successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. @@ -221,7 +220,7 @@ defmodule Bookkeeping do # iex> Bookkeeping.create_journal_entry(%{ # ...> transaction_date: ~U[2021-10-10 10:10:10.000000Z], - # ...> general_ledger_posting_date: ~U[2021-10-10 10:10:10.000000Z], + # ...> posting_date: ~U[2021-10-10 10:10:10.000000Z], # ...> t_accounts: %{ # ...> left: [ # ...> %{ @@ -236,10 +235,10 @@ defmodule Bookkeeping do # ...> } # ...> ]F # ...> }, - # ...> journal_entry_number: "JE001001", - # ...> transaction_reference_number: "INV001001", - # ...> journal_entry_description: "description", - # ...> journal_entry_details: %{}, + # ...> document_number: "JE001001", + # ...> reference_number: "INV001001", + # ...> description: "description", + # ...> particulars: %{}, # ...> audit_details: %{} # ...> }) # %{:ok, %Bookkeeping.Core.JournalEntry{...}} @@ -291,11 +290,11 @@ defmodule Bookkeeping do # defdelegate find_journal_entry_by_journal_entry_number(journal_entry_number), # to: AccountingJournal - # @spec find_journal_entries_by_general_ledger_posting_date( + # @spec find_journal_entries_by_posting_date( # DateTime.t() - # | AccountingJournal.general_ledger_posting_date_details() + # | AccountingJournal.posting_date_details() # ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} - # defdelegate find_journal_entries_by_general_ledger_posting_date(datetime), to: AccountingJournal + # defdelegate find_journal_entries_by_posting_date(datetime), to: AccountingJournal # @doc """ # Returns a journal entry by id. @@ -320,20 +319,20 @@ defmodule Bookkeeping do # ## Examples - # iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) + # iex> Bookkeeping.find_journal_entries_by_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) # {:ok, [%JournalEntry{...}]} - # iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(%{year: 2021, month: 10, day: 10}, %{year: 2021, month: 10, day: 10}) + # iex> Bookkeeping.find_journal_entries_by_posting_date_range(%{year: 2021, month: 10, day: 10}, %{year: 2021, month: 10, day: 10}) # {:ok, [%JournalEntry{...}]} - # iex> Bookkeeping.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) + # iex> Bookkeeping.find_journal_entries_by_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) # {:error, :invalid_date} # """ - # @spec find_journal_entries_by_general_ledger_posting_date_range( - # DateTime.t() | AccountingJournal.general_ledger_posting_date_details(), - # DateTime.t() | AccountingJournal.general_ledger_posting_date_details() + # @spec find_journal_entries_by_posting_date_range( + # DateTime.t() | AccountingJournal.posting_date_details(), + # DateTime.t() | AccountingJournal.posting_date_details() # ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} - # defdelegate find_journal_entries_by_general_ledger_posting_date_range( + # defdelegate find_journal_entries_by_posting_date_range( # from_datetime, # to_datetime # ), @@ -349,10 +348,10 @@ defmodule Bookkeeping do # iex> Bookkeeping.find_journal_entry_by_journal_entry_number("ref_num_1") # {:ok, %JournalEntry{...}} - # iex> Bookkeeping.update_journal_entry(%JournalEntry{...}, %{journal_entry_description: "updated description",posted: true}) - # {:ok, %JournalEntry{journal_entry_description: "updated description", posted: true, ...}} + # iex> Bookkeeping.update_journal_entry(%JournalEntry{...}, %{description: "updated description",posted: true}) + # {:ok, %JournalEntry{description: "updated description", posted: true, ...}} - # iex> Bookkeeping.update_journal_entry(%JournalEntry{}, %{journal_entry_description: "updated description",posted: true}) + # iex> Bookkeeping.update_journal_entry(%JournalEntry{}, %{description: "updated description",posted: true}) # {:error, :invalid_journal_entry} # """ # @spec update_journal_entry(JournalEntry.t(), map()) :: diff --git a/lib/bookkeeping/application.ex b/lib/bookkeeping/application.ex index 0fd7f62..5c0d1e9 100644 --- a/lib/bookkeeping/application.ex +++ b/lib/bookkeeping/application.ex @@ -5,7 +5,7 @@ defmodule Bookkeeping.Application do use Application - alias Bookkeeping.Boundary.AccountingJournal.Supervisor, as: AccountingJournalSupervisor + # alias Bookkeeping.Boundary.AccountingJournal.Supervisor, as: AccountingJournalSupervisor alias Bookkeeping.Boundary.ChartOfAccounts.Supervisor, as: ChartOfAccountsSupervisor @impl true @@ -13,8 +13,8 @@ defmodule Bookkeeping.Application do children = [ # Starts a worker by calling: Bookkeeping.Worker.start_link(arg) # {Bookkeeping.Worker, arg} - {ChartOfAccountsSupervisor, [name: ChartOfAccountsSupervisor]}, - {AccountingJournalSupervisor, [name: AccountingJournalSupervisor]} + {ChartOfAccountsSupervisor, [name: ChartOfAccountsSupervisor]} + # {AccountingJournalSupervisor, [name: AccountingJournalSupervisor]} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/bookkeeping/boundary/accounting_journal/server.ex b/lib/bookkeeping/boundary/accounting_journal/server.ex index 34a281d..02c7161 100644 --- a/lib/bookkeeping/boundary/accounting_journal/server.ex +++ b/lib/bookkeeping/boundary/accounting_journal/server.ex @@ -23,9 +23,9 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do ...> %JournalEntry{ ...> id: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", ...> transaction_date: ~U[2021-10-10 10:10:10.000000Z], - ...> journal_entry_number: "reference number", - ...> journal_entry_description: "description", - ...> journal_entry_details: %{}, + ...> document_number: "reference number", + ...> description: "description", + ...> particulars: %{}, ...> line_items: [ ...> %LineItem{ ...> account: %Account{ @@ -73,848 +73,848 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do ...> ... ...> } """ - @type accounting_journal_state :: %{ - general_ledger_posting_date_details => list(JournalEntry.t()) - } - @type accounting_journal_server_pid :: atom | pid | {atom, any} | {:via, atom, any} - @type create_journal_entry_params :: %{ - transaction_date: DateTime.t(), - general_ledger_posting_date: DateTime.t(), - t_accounts: accounting_journal_t_accounts(), - journal_entry_number: String.t(), - transaction_reference_number: String.t(), - journal_entry_description: String.t(), - journal_entry_details: map(), - audit_details: map() - } - @type general_ledger_posting_date_details :: %{ - year: integer(), - month: integer(), - day: integer() - } - @type accounting_journal_t_accounts :: %{ - left: list(LineItem.t()), - right: list(LineItem.t()) - } - - @doc """ - Starts the Accounting Journal GenServer. - - Returns `{:ok, pid}` if the GenServer is started successfully. - - ## Examples - - iex> AccountingJournal.start_link() - {:ok, #PID<0.123.0>} - """ - @spec start_link(Keyword.t()) :: {:ok, pid} - def start_link(options \\ []) do - GenServer.start_link(__MODULE__, %{}, options) - end - - @doc """ - Creates a journal entry. - - Arguments: - - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) - - general_ledger_posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. - - t_accounts: The map of line items. The map must have the following keys: - - left: The list of maps with account and amount field and represents the entry type of debit. - - right: The list of maps with account and amount field and represents the entry type of credit. - - journal_entry_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). - - transaction_reference_number (optional): The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) - - journal_entry_description (optional): The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) - - journal_entry_details (optional): The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) - - audit_details (optional): The details of the audit log. - - Returns `{:ok, JournalEntry.t()}` if the journal entry is created successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.create_journal_entry(%{ - ...> transaction_date: ~U[2021-10-10 10:10:10.000000Z], - ...> general_ledger_posting_date: ~U[2021-10-10 10:10:10.000000Z], - ...> t_accounts: %{ - ...> left: [ - ...> %{ - ...> account: "Cash", - ...> amount: Decimal.new(100) - ...> } - ...> ], - ...> right: [ - ...> %{ - ...> account: "Sales Revenue", - ...> amount: Decimal.new(100) - ...> } - ...> ]F - ...> }, - ...> journal_entry_number: "JE001001", - ...> transaction_reference_number: "INV001001", - ...> journal_entry_description: "description", - ...> journal_entry_details: %{}, - ...> audit_details: %{} - ...> }) - %{:ok, %Bookkeeping.Core.JournalEntry{...}} - """ - @spec create_journal_entry(accounting_journal_server_pid(), create_journal_entry_params()) :: - {:ok, JournalEntry.t()} | {:error, :invalid_journal_entry} - def create_journal_entry(server \\ __MODULE__, create_journal_entry_params) do - create_journal_record(server, create_journal_entry_params) - end - - @doc """ - Imports journal entries from a CSV file. - The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` - - Arguments: - - path: The path of the CSV file. - - Returns `{:ok, %{ok: list(JournalEntry.t()), error: list(map())}}` if the journal entries are imported successfully. Otherwise, returns `{:error, %{message: :invalid_csv, errors: list(map())}}`. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.import_journal_entries(server, "../../data/sample_journal_entries.csv") - {:ok, - %{ - error: [], - ok: [%Bookkeeping.Core.JournalEntry{...}, %Bookkeeping.Core.JournalEntry{...}, ...] - }} - """ - @spec import_journal_entries(accounting_journal_server_pid(), String.t()) :: - {:ok, %{ok: list(JournalEntry.t()), error: list(map())}} - | {:error, %{ok: list(JournalEntry.t()), error: list(map())}} - | {:error, %{message: :invalid_csv, errors: list(map())}} - | {:error, :invalid_file} - def import_journal_entries(server \\ __MODULE__, path) do - with file_path <- Path.expand(path, __DIR__), - true <- File.exists?(file_path), - {:ok, csv} <- read_csv(file_path) do - bulk_create_journal_entries(server, csv) - else - _error -> {:error, :invalid_file} - end - end - - @doc """ - Returns all journal entries. - - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.all_journal_entries() - {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} - """ - @spec all_journal_entries(accounting_journal_server_pid()) :: {:ok, list(JournalEntry.t())} - def all_journal_entries(server \\ __MODULE__) do - GenServer.call(server, :all_journal_entries) - end - - @doc """ - Returns a journal entry by reference number. - - Returns `{:ok, JournalEntry.t()}` if the journal entry is returned successfully. Otherwise, returns `{:error, :not_found}`. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entry_by_journal_entry_number("JE001001") - {:ok, %JournalEntry{...}} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entry_by_journal_entry_number("ref_num_2") - {:error, :not_found} - """ - @spec find_journal_entry_by_journal_entry_number(accounting_journal_server_pid(), String.t()) :: - {:ok, JournalEntry.t()} | {:error, :not_found} - def find_journal_entry_by_journal_entry_number(server \\ __MODULE__, journal_entry_number) do - GenServer.call(server, {:find_journal_entry_by_journal_entry_number, journal_entry_number}) - end - - @doc """ - Returns a list of journal entries by general ledger posting date. - - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. Otherwise, returns `{:error, :invalid_date}`. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_general_ledger_posting_date(~U[2021-10-10 10:10:10.000000Z]) - {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_general_ledger_posting_date(%{year: 2021, month: 10}) - {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_general_ledger_posting_date(~U[2021-10-10 10:10:10.000000Z]) - {:error, :invalid_date} - """ - @spec find_journal_entries_by_general_ledger_posting_date( - accounting_journal_server_pid(), - DateTime.t() | general_ledger_posting_date_details() - ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} - def find_journal_entries_by_general_ledger_posting_date(server \\ __MODULE__, datetime) do - GenServer.call(server, {:find_journal_entries_by_general_ledger_posting_date, datetime}) - end - - @doc """ - Returns a journal entry by id. - - Returns `{:ok, JournalEntry.t()}` if the journal entry is returned successfully. Otherwise, returns `{:error, :invalid_id}`. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - {:ok, %JournalEntry{...}} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - {:error, :invalid_id} - """ - @spec find_journal_entries_by_id(accounting_journal_server_pid(), String.t()) :: - {:ok, JournalEntry.t()} | {:error, :invalid_id} - def find_journal_entries_by_id(server \\ __MODULE__, id) do - GenServer.call(server, {:find_journal_entries_by_id, id}) - end - - @doc """ - Returns a list of journal entries by general ledger posting date range. - - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. Otherwise, returns `{:error, :invalid_date}`. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) - {:ok, [%JournalEntry{...}]} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_general_ledger_posting_date_range(%{year: 2021, month: 10, day: 10}, %{year: 2021, month: 10, day: 10}) - {:ok, [%JournalEntry{...}]} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_general_ledger_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) - {:error, :invalid_date} - """ - @spec find_journal_entries_by_general_ledger_posting_date_range( - accounting_journal_server_pid(), - DateTime.t() | general_ledger_posting_date_details(), - DateTime.t() | general_ledger_posting_date_details() - ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} - def find_journal_entries_by_general_ledger_posting_date_range( - server \\ __MODULE__, - from_datetime, - to_datetime - ) do - GenServer.call( - server, - {:find_journal_entries_by_general_ledger_posting_date_range, from_datetime, to_datetime} - ) - end - - @doc """ - Updates a journal entry. - - Returns `{:ok, JournalEntry.t()}` if the journal entry is updated successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entry_by_journal_entry_number("ref_num_1") - {:ok, %JournalEntry{...}} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.update_journal_entry(%JournalEntry{...}, %{journal_entry_description: "updated description",posted: true}) - {:ok, %JournalEntry{journal_entry_description: "updated description", posted: true, ...}} - - iex> Bookkeeping.Boundary.AccountingJournal.Server.update_journal_entry(%JournalEntry{}, %{journal_entry_description: "updated description",posted: true}) - {:error, :invalid_journal_entry} - """ - @spec update_journal_entry(accounting_journal_server_pid(), JournalEntry.t(), map()) :: - {:ok, JournalEntry.t()} - | {:error, :invalid_journal_entry} - | {:error, :already_posted_journal_entry} - def update_journal_entry(server \\ __MODULE__, journal_entry, attrs) do - GenServer.call(server, {:update_journal_entry, journal_entry, attrs}) - end - - @doc """ - Resets the journal entries. - - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are reset successfully. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.reset_journal_entries() - {:ok, []} - """ - @spec reset_journal_entries(accounting_journal_server_pid()) :: {:ok, list(JournalEntry.t())} - def reset_journal_entries(server \\ __MODULE__) do - GenServer.call(server, :reset_journal_entries) - end - - @doc """ - Returns the state of the Accounting Journal GenServer. - - Returns `{:ok, accounting_journal_state()}` if the state is returned successfully. - - ## Examples - - iex> Bookkeeping.Boundary.AccountingJournal.Server.get_accounting_journal_state() - {:ok, %{...}} - """ - @spec get_accounting_journal_state(accounting_journal_server_pid()) :: - {:ok, accounting_journal_state()} - def get_accounting_journal_state(server \\ __MODULE__) do - GenServer.call(server, :get_accounting_journal_state) - end - - @impl true - @spec init(accounting_journal_state()) :: {:ok, accounting_journal_state()} - def init(_journal_entries) do - AccountingJournalBackup.get() - end - - @impl true - def handle_call({:create_journal_entry, params}, _from, journal_entries) do - with {:error, :not_found} <- - find_by_journal_entry_number(journal_entries, params.journal_entry_number), - {:ok, journal_entry} <- - JournalEntry.create( - params.transaction_date, - params.general_ledger_posting_date, - params.t_accounts, - params.journal_entry_number, - params.transaction_reference_number, - params.journal_entry_description, - params.journal_entry_details, - params.audit_details - ) do - date_details = Map.take(journal_entry.general_ledger_posting_date, [:year, :month, :day]) - - updated_journal_entries = - if journal_entries[date_details] == nil do - Map.put(journal_entries, date_details, [journal_entry]) - else - updated_je_list = [journal_entry | journal_entries[date_details]] - Map.put(journal_entries, date_details, updated_je_list) - end - - {:reply, {:ok, journal_entry}, updated_journal_entries, :hibernate} - else - {:ok, _journal_entry} -> - {:reply, {:error, :duplicate_journal_entry_number}, journal_entries, :hibernate} - - {:error, message} -> - {:reply, {:error, message}, journal_entries, :hibernate} - end - end - - @impl true - def handle_call({:update_journal_entry, journal_entry, attrs}, _from, journal_entries) do - case JournalEntry.update(journal_entry, attrs) do - {:ok, updated_journal_entry} -> - updated_journal_entries = - process_journal_entry_update(journal_entries, updated_journal_entry) - - {:reply, {:ok, updated_journal_entry}, updated_journal_entries, :hibernate} - - {:error, message} -> - {:reply, {:error, message}, journal_entries, :hibernate} - end - end - - @impl true - def handle_call(:all_journal_entries, from, journal_entries) do - Task.async(fn -> - all_entries = - Enum.reduce(journal_entries, [], fn {_k, je_list}, acc -> je_list ++ acc end) - - GenServer.reply(from, {:ok, all_entries}) - end) - - {:noreply, journal_entries} - end - - @impl true - def handle_call( - {:find_journal_entry_by_journal_entry_number, journal_entry_number}, - from, - journal_entries - ) do - Task.async(fn -> - case find_by_journal_entry_number(journal_entries, journal_entry_number) do - {:ok, journal_entry} -> GenServer.reply(from, {:ok, journal_entry}) - {:error, message} -> GenServer.reply(from, {:error, message}) - end - end) - - {:noreply, journal_entries} - end - - @impl true - def handle_call( - {:find_journal_entries_by_general_ledger_posting_date, datetime}, - from, - journal_entries - ) do - Task.async(fn -> - case get_date_details(datetime) do - {:ok, date_details} -> - all_journal_entries = - find_journal_entries_by_posting_date(journal_entries, date_details) - - GenServer.reply(from, {:ok, all_journal_entries}) - - {:error, message} -> - GenServer.reply(from, {:error, message}) - end - end) - - {:noreply, journal_entries} - end - - @impl true - def handle_call({:find_journal_entries_by_id, id}, from, journal_entries) do - Task.async(fn -> - case find_by_id(journal_entries, id) do - {:ok, journal_entry} -> GenServer.reply(from, {:ok, journal_entry}) - {:error, message} -> GenServer.reply(from, {:error, message}) - end - end) - - {:noreply, journal_entries} - end - - @impl true - def handle_call( - {:find_journal_entries_by_general_ledger_posting_date_range, from_datetime, to_datetime}, - from, - journal_entries - ) do - Task.async(fn -> - with {:ok, from_date_details} <- get_date_details(from_datetime), - {:ok, to_date_details} <- get_date_details(to_datetime) do - je_list = - find_journal_entries_by_date_range(journal_entries, from_date_details, to_date_details) - - GenServer.reply(from, {:ok, je_list}) - else - {:error, message} -> GenServer.reply(from, {:error, message}) - end - end) - - {:noreply, journal_entries} - end - - @impl true - def handle_call(:reset_journal_entries, _from, _journal_entries) do - AccountingJournalBackup.update(%{}) - {:reply, {:ok, []}, %{}} - end - - @impl true - def handle_call(:get_accounting_journal_state, from, journal_entries) do - Task.async(fn -> GenServer.reply(from, {:ok, journal_entries}) end) - {:noreply, journal_entries} - end - - @impl true - def handle_info(_msg, state) do - {:noreply, state} - end - - @impl true - def terminate(_reason, journal_entries) do - AccountingJournalBackup.update(journal_entries) - end - - defp get_date_details(datetime) when is_struct(datetime, DateTime), - do: {:ok, Map.take(datetime, [:year, :month, :day])} - - defp get_date_details(%{year: year, month: month, day: day}), - do: {:ok, %{year: year, month: month, day: day}} - - defp get_date_details(_), do: {:error, :invalid_date} - - defp find_journal_entries_by_posting_date(journal_entries, date_details) do - tdd_keys = Map.keys(date_details) - - journal_entries - |> Task.async_stream(fn {k, je} -> - if Map.take(k, tdd_keys) == date_details, do: je, else: nil - end) - |> Enum.reduce([], fn {:ok, je_list}, acc -> - if is_list(je_list), do: je_list ++ acc, else: acc - end) - end - - defp find_journal_entries_by_date_range(journal_entries, from_date_details, to_date_details) do - from_datetime = convert_date_details_to_datetime(from_date_details, "from") - to_datetime = convert_date_details_to_datetime(to_date_details, "to") - - journal_entries - |> Task.async_stream(fn {k, je_list} -> - k_datetime = convert_date_details_to_datetime(k, "from") - - if DateTime.compare(to_datetime, k_datetime) == :gt and - DateTime.compare(k_datetime, from_datetime) in [:gt, :eq], - do: je_list, - else: nil - end) - |> Enum.reduce([], fn {:ok, je_list}, acc -> - if is_list(je_list), do: je_list ++ acc, else: acc - end) - end - - defp find_by_journal_entry_number(journal_entries, journal_entry_number) - when is_binary(journal_entry_number) do - journal_entry_found = - journal_entries - |> Task.async_stream(fn {_k, je_list} -> - Enum.find(je_list, &(&1.journal_entry_number == journal_entry_number)) - end) - |> Enum.reduce(nil, fn {:ok, search_result}, acc -> - if is_struct(search_result, JournalEntry), do: search_result, else: acc - end) - - if is_map(journal_entry_found), - do: {:ok, journal_entry_found}, - else: {:error, :not_found} - end - - defp find_by_journal_entry_number(_journal_entries, _journal_entry_number), - do: {:error, :invalid_journal_entry_number} - - defp find_by_id(journal_entries, id) when is_binary(id) do - journal_entry_found = - journal_entries - |> Task.async_stream(fn {_k, je_list} -> Enum.find(je_list, &(&1.id == id)) end) - |> Enum.reduce(nil, fn {:ok, search_result}, acc -> - if is_struct(search_result, JournalEntry), do: search_result, else: acc - end) - - if is_map(journal_entry_found), - do: {:ok, journal_entry_found}, - else: {:error, :not_found} - end - - defp find_by_id(_journal_entries, _id), do: {:error, :invalid_id} - - defp process_journal_entry_update(journal_entries, updated_journal_entry) do - date_details = - Map.take(updated_journal_entry.general_ledger_posting_date, [:year, :month, :day]) - - if journal_entries[date_details] == nil do - with {:ok, old_journal_entry} <- find_by_id(journal_entries, updated_journal_entry.id), - {:ok, old_date_details} <- - get_date_details(old_journal_entry.general_ledger_posting_date) do - updated_je_list = - remove_journal_entry_by_id( - journal_entries, - old_date_details, - old_journal_entry.id - ) - - journal_entries - |> Map.put(date_details, [updated_journal_entry]) - |> Map.put(old_date_details, updated_je_list) - end - else - updated_je_list = - update_journal_entry_by_id( - journal_entries, - date_details, - updated_journal_entry - ) - - Map.put(journal_entries, date_details, updated_je_list) - end - end - - defp remove_journal_entry_by_id( - journal_entries, - date_details, - journal_entry_id - ) do - Enum.filter(journal_entries[date_details], fn je -> - je.id != journal_entry_id - end) - end - - defp update_journal_entry_by_id( - journal_entries, - date_details, - updated_journal_entry - ) do - Enum.map(journal_entries[date_details], fn je -> - if je.id == updated_journal_entry.id, do: updated_journal_entry, else: je - end) - end - - defp create_journal_record(server, params) do - t_accounts = params |> Map.get(:t_accounts, %{}) |> update_t_accounts() - updated_params = Map.put(params, :t_accounts, t_accounts) - - GenServer.call(server, {:create_journal_entry, updated_params}) - end - - defp update_t_accounts(t_accounts) do - %{ - left: update_account_amount_pair(t_accounts.left), - right: update_account_amount_pair(t_accounts.right) - } - end - - defp update_account_amount_pair(account_amount_pairs) do - Enum.reduce(account_amount_pairs, [], fn t_account, acc -> - case ChartOfAccountsServer.find_account_by_name(t_account.account) do - {:ok, account} -> acc ++ [Map.put(t_account, :account, account)] - _ -> acc ++ [t_account] - end - end) - end - - defp bulk_create_journal_entries(server, csv) when is_list(csv) and csv != [] do - with %{ok: ok_create_params, error: []} <- generate_bulk_create_params(csv), - {:ok, result} <- bulk_create_je_records(server, ok_create_params) do - {:ok, result} - else - %{ok: _ok_create_params, error: errors} -> - {:error, %{message: :invalid_csv, errors: errors}} - - {:error, result} -> - {:error, result} - end - end - - defp bulk_create_journal_entries(_server, _csv), do: {:error, :invalid_file} - - defp bulk_create_je_records(server, create_params_list) do - result = - Enum.reduce(create_params_list, %{ok: [], error: []}, fn params, acc -> - case create_journal_record(server, params) do - {:ok, journal_entry} -> - Map.put(acc, :ok, [journal_entry | acc.ok]) - - {:error, message} -> - errors = - acc.error ++ [%{journal_entry_number: params.journal_entry_number, error: message}] - - Map.put(acc, :error, errors) - end - end) - - if result.ok == [], do: {:error, result}, else: {:ok, result} - end - - defp generate_bulk_create_params(csv) do - Enum.reduce(csv, %{ok: [], error: []}, fn csv_item, acc -> - journal_entry_number = Map.get(csv_item, "Journal Entry Number", "") - transaction_reference_number = Map.get(csv_item, "Transaction Reference Number", "") - csv_posted = Map.get(csv_item, "Posted", "no") - journal_entry_description = Map.get(csv_item, "Journal Entry Description", "") - journal_entry_details = Map.get(csv_item, "Journal Entry Details", "{}") - audit_details = Map.get(csv_item, "Audit Details", "{}") - description = Map.get(csv_item, "Line Item Description", "") - - posted_field = csv_posted |> String.trim() |> String.downcase() - posted = if posted_field == "yes", do: true, else: false - - updated_journal_entry_description = - generate_updated_journal_description( - acc.ok, - journal_entry_number, - journal_entry_description - ) - - with true <- - validate_csv_items( - journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, - audit_details, - csv_posted, - description - ), - {:ok, transaction_date} <- parse_date(csv_item, "Transaction Date"), - {:ok, general_ledger_posting_date} <- - parse_date(csv_item, "General Ledger Posting Date"), - {:ok, journal_entry_details} <- Jason.decode(journal_entry_details), - {:ok, audit_details} <- Jason.decode(audit_details) do - initial_params = %{ - t_accounts: %{left: [], right: []}, - posted: posted, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_description: updated_journal_entry_description, - description: description, - transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, - journal_entry_details: journal_entry_details, - audit_details: audit_details - } - - oks = - update_ok_params( - acc.ok, - csv_item, - initial_params, - journal_entry_number - ) - - Map.put(acc, :ok, oks) - else - {:error, error} -> - errors = acc.error ++ [%{journal_entry_number: journal_entry_number, error: error}] - Map.put(acc, :error, errors) - - _error -> - errors = - acc.error ++ [%{journal_entry_number: journal_entry_number, error: :invalid_csv_item}] - - Map.put(acc, :error, errors) - end - end) - end - - defp validate_csv_items( - journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, - audit_details, - csv_posted, - description - ) do - is_binary(journal_entry_number) and journal_entry_number != "" and - is_binary(transaction_reference_number) and is_binary(journal_entry_description) and - is_binary(journal_entry_details) and is_binary(audit_details) and is_binary(csv_posted) and - is_binary(description) - end - - defp generate_updated_journal_description( - acc_ok_params, - journal_entry_number, - journal_entry_description - ) do - existing_journal_entry_description = - acc_ok_params - |> Enum.find(%{}, fn param -> param.journal_entry_number == journal_entry_number end) - |> Map.get(:journal_entry_description, "") - - cond do - existing_journal_entry_description == "" -> - journal_entry_description - - existing_journal_entry_description != "" and journal_entry_description == "" -> - existing_journal_entry_description - - existing_journal_entry_description != "" and journal_entry_description != "" -> - existing_journal_entry_description <> " " <> journal_entry_description - end - end - - defp update_ok_params( - ok_params, - csv_item, - initial_params, - journal_entry_number - ) do - account = Map.get(csv_item, "Name", "") - description = Map.get(csv_item, "Line Item Description", "") - debit = Map.get(csv_item, "Debit", "") - credit = Map.get(csv_item, "Credit", "") - - case Enum.find(ok_params, fn param -> param.journal_entry_number == journal_entry_number end) do - nil -> - updated_t_accounts = - set_t_accounts(debit, credit, account, description, initial_params) - - params = Map.put(initial_params, :t_accounts, updated_t_accounts) - - ok_params ++ [params] - - found_param -> - updated_t_accounts = - set_t_accounts(debit, credit, account, description, found_param) - - Enum.map(ok_params, fn - %{journal_entry_number: je_number} when je_number == journal_entry_number -> - Map.put(initial_params, :t_accounts, updated_t_accounts) - - param -> - param - end) - end - end - - defp set_t_accounts(debit, "" = _credit, account, description, params) do - debit_amount = if debit == "", do: "0", else: Decimal.new(debit) - - t_accounts_debit_item = %{ - account: account, - amount: Decimal.new(debit_amount), - description: description - } - - %{ - left: [t_accounts_debit_item] ++ params.t_accounts.left, - right: params.t_accounts.right - } - end - - defp set_t_accounts("" = _debit, credit, account, description, params) do - credit_amount = if credit == "", do: "0", else: Decimal.new(credit) - - t_accounts_credit_item = %{ - account: account, - amount: Decimal.new(credit_amount), - description: description - } - - %{ - left: params.t_accounts.left, - right: [t_accounts_credit_item] ++ params.t_accounts.right - } - end - - defp parse_date(csv_item, date_column_header) do - result = - csv_item - |> Map.get(date_column_header, "") - |> String.split("-") - - if length(result) == 3 do - [month, day, year] = result - {:ok, datetime, _} = DateTime.from_iso8601("#{year}-#{month}-#{day}T00:00:00Z") - {:ok, datetime} - else - {:error, :invalid_date} - end - end - - defp read_csv(path) do - csv_inputs = - path - |> File.stream!() - |> CSV.parse_stream(skip_headers: false) - |> Stream.transform(nil, fn - headers, nil -> {[], headers} - row, headers -> {[Enum.zip(headers, row) |> Map.new()], headers} - end) - |> Enum.to_list() - - {:ok, csv_inputs} - end - - defp convert_date_details_to_datetime(date_details, date_type) do - year = Map.get(date_details, :year, 1000) - month = Map.get(date_details, :month, 1) - day = Map.get(date_details, :day, 1) - time = if date_type == "from", do: "00:00:00Z", else: "23:59:59Z" - - if is_integer(year) and is_integer(month) and is_integer(day) do - month = day_or_month_to_string(month) - day = day_or_month_to_string(day) - {:ok, datetime, _} = DateTime.from_iso8601("#{year}-#{month}-#{day}T#{time}") - datetime - else - {:error, :invalid_date} - end - end - - defp day_or_month_to_string(day_or_month) when day_or_month < 10, do: "0#{day_or_month}" - defp day_or_month_to_string(day_or_month), do: "#{day_or_month}" + # @type accounting_journal_state :: %{ + # posting_date_details => list(JournalEntry.t()) + # } + # @type accounting_journal_server_pid :: atom | pid | {atom, any} | {:via, atom, any} + # @type create_journal_entry_params :: %{ + # transaction_date: DateTime.t(), + # posting_date: DateTime.t(), + # t_accounts: accounting_journal_t_accounts(), + # document_number: String.t(), + # reference_number: String.t(), + # description: String.t(), + # particulars: map(), + # audit_details: map() + # } + # @type posting_date_details :: %{ + # year: integer(), + # month: integer(), + # day: integer() + # } + # @type accounting_journal_t_accounts :: %{ + # left: list(LineItem.t()), + # right: list(LineItem.t()) + # } + + # @doc """ + # Starts the Accounting Journal GenServer. + + # Returns `{:ok, pid}` if the GenServer is started successfully. + + # ## Examples + + # iex> AccountingJournal.start_link() + # {:ok, #PID<0.123.0>} + # """ + # @spec start_link(Keyword.t()) :: {:ok, pid} + # def start_link(options \\ []) do + # GenServer.start_link(__MODULE__, %{}, options) + # end + + # @doc """ + # Creates a journal entry. + + # Arguments: + # - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) + # - posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. + # - t_accounts: The map of line items. The map must have the following keys: + # - left: The list of maps with account and amount field and represents the entry type of debit. + # - right: The list of maps with account and amount field and represents the entry type of credit. + # - document_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). + # - reference_number (optional): The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) + # - description (optional): The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) + # - particulars (optional): The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) + # - audit_details (optional): The details of the audit log. + + # Returns `{:ok, JournalEntry.t()}` if the journal entry is created successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.create_journal_entry(%{ + # ...> transaction_date: ~U[2021-10-10 10:10:10.000000Z], + # ...> posting_date: ~U[2021-10-10 10:10:10.000000Z], + # ...> t_accounts: %{ + # ...> left: [ + # ...> %{ + # ...> account: "Cash", + # ...> amount: Decimal.new(100) + # ...> } + # ...> ], + # ...> right: [ + # ...> %{ + # ...> account: "Sales Revenue", + # ...> amount: Decimal.new(100) + # ...> } + # ...> ]F + # ...> }, + # ...> document_number: "JE001001", + # ...> reference_number: "INV001001", + # ...> description: "description", + # ...> particulars: %{}, + # ...> audit_details: %{} + # ...> }) + # %{:ok, %Bookkeeping.Core.JournalEntry{...}} + # """ + # @spec create_journal_entry(accounting_journal_server_pid(), create_journal_entry_params()) :: + # {:ok, JournalEntry.t()} | {:error, :invalid_journal_entry} + # def create_journal_entry(server \\ __MODULE__, create_journal_entry_params) do + # create_journal_record(server, create_journal_entry_params) + # end + + # @doc """ + # Imports journal entries from a CSV file. + # The header of the CSV file must be `Journal Entry Number`, `Transaction Date`, `Name`, `Debit`, `Credit`, `Line Item Description`, `Posted`, `Journal Entry Description`, `Journal Entry Details`, `Audit Details`, `General Ledger Posting Date`, and `Transaction Reference Number` + + # Arguments: + # - path: The path of the CSV file. + + # Returns `{:ok, %{ok: list(JournalEntry.t()), error: list(map())}}` if the journal entries are imported successfully. Otherwise, returns `{:error, %{message: :invalid_csv, errors: list(map())}}`. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.import_journal_entries(server, "../../data/sample_journal_entries.csv") + # {:ok, + # %{ + # error: [], + # ok: [%Bookkeeping.Core.JournalEntry{...}, %Bookkeeping.Core.JournalEntry{...}, ...] + # }} + # """ + # @spec import_journal_entries(accounting_journal_server_pid(), String.t()) :: + # {:ok, %{ok: list(JournalEntry.t()), error: list(map())}} + # | {:error, %{ok: list(JournalEntry.t()), error: list(map())}} + # | {:error, %{message: :invalid_csv, errors: list(map())}} + # | {:error, :invalid_file} + # def import_journal_entries(server \\ __MODULE__, path) do + # with file_path <- Path.expand(path, __DIR__), + # true <- File.exists?(file_path), + # {:ok, csv} <- read_csv(file_path) do + # bulk_create_journal_entries(server, csv) + # else + # _error -> {:error, :invalid_file} + # end + # end + + # @doc """ + # Returns all journal entries. + + # Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.all_journal_entries() + # {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} + # """ + # @spec all_journal_entries(accounting_journal_server_pid()) :: {:ok, list(JournalEntry.t())} + # def all_journal_entries(server \\ __MODULE__) do + # GenServer.call(server, :all_journal_entries) + # end + + # @doc """ + # Returns a journal entry by reference number. + + # Returns `{:ok, JournalEntry.t()}` if the journal entry is returned successfully. Otherwise, returns `{:error, :not_found}`. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entry_by_journal_entry_number("JE001001") + # {:ok, %JournalEntry{...}} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entry_by_journal_entry_number("ref_num_2") + # {:error, :not_found} + # """ + # @spec find_journal_entry_by_journal_entry_number(accounting_journal_server_pid(), String.t()) :: + # {:ok, JournalEntry.t()} | {:error, :not_found} + # def find_journal_entry_by_journal_entry_number(server \\ __MODULE__, journal_entry_number) do + # GenServer.call(server, {:find_journal_entry_by_journal_entry_number, journal_entry_number}) + # end + + # @doc """ + # Returns a list of journal entries by general ledger posting date. + + # Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. Otherwise, returns `{:error, :invalid_date}`. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_posting_date(~U[2021-10-10 10:10:10.000000Z]) + # {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_posting_date(%{year: 2021, month: 10}) + # {:ok, [%JournalEntry{...}, %JournalEntry{...}, ...]} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_posting_date(~U[2021-10-10 10:10:10.000000Z]) + # {:error, :invalid_date} + # """ + # @spec find_journal_entries_by_posting_date( + # accounting_journal_server_pid(), + # DateTime.t() | posting_date_details() + # ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} + # def find_journal_entries_by_posting_date(server \\ __MODULE__, datetime) do + # GenServer.call(server, {:find_journal_entries_by_posting_date, datetime}) + # end + + # @doc """ + # Returns a journal entry by id. + + # Returns `{:ok, JournalEntry.t()}` if the journal entry is returned successfully. Otherwise, returns `{:error, :invalid_id}`. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + # {:ok, %JournalEntry{...}} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_id("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + # {:error, :invalid_id} + # """ + # @spec find_journal_entries_by_id(accounting_journal_server_pid(), String.t()) :: + # {:ok, JournalEntry.t()} | {:error, :invalid_id} + # def find_journal_entries_by_id(server \\ __MODULE__, id) do + # GenServer.call(server, {:find_journal_entries_by_id, id}) + # end + + # @doc """ + # Returns a list of journal entries by general ledger posting date range. + + # Returns `{:ok, list(JournalEntry.t())}` if the journal entries are returned successfully. Otherwise, returns `{:error, :invalid_date}`. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) + # {:ok, [%JournalEntry{...}]} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_posting_date_range(%{year: 2021, month: 10, day: 10}, %{year: 2021, month: 10, day: 10}) + # {:ok, [%JournalEntry{...}]} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entries_by_posting_date_range(~U[2021-10-10 10:10:10.000000Z], ~U[2021-10-10 10:10:10.000000Z]) + # {:error, :invalid_date} + # """ + # @spec find_journal_entries_by_posting_date_range( + # accounting_journal_server_pid(), + # DateTime.t() | posting_date_details(), + # DateTime.t() | posting_date_details() + # ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} + # def find_journal_entries_by_posting_date_range( + # server \\ __MODULE__, + # from_datetime, + # to_datetime + # ) do + # GenServer.call( + # server, + # {:find_journal_entries_by_posting_date_range, from_datetime, to_datetime} + # ) + # end + + # @doc """ + # Updates a journal entry. + + # Returns `{:ok, JournalEntry.t()}` if the journal entry is updated successfully. Otherwise, returns `{:error, :invalid_journal_entry}`. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.find_journal_entry_by_journal_entry_number("ref_num_1") + # {:ok, %JournalEntry{...}} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.update_journal_entry(%JournalEntry{...}, %{description: "updated description",posted: true}) + # {:ok, %JournalEntry{description: "updated description", posted: true, ...}} + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.update_journal_entry(%JournalEntry{}, %{description: "updated description",posted: true}) + # {:error, :invalid_journal_entry} + # """ + # @spec update_journal_entry(accounting_journal_server_pid(), JournalEntry.t(), map()) :: + # {:ok, JournalEntry.t()} + # | {:error, :invalid_journal_entry} + # | {:error, :already_posted_journal_entry} + # def update_journal_entry(server \\ __MODULE__, journal_entry, attrs) do + # GenServer.call(server, {:update_journal_entry, journal_entry, attrs}) + # end + + # @doc """ + # Resets the journal entries. + + # Returns `{:ok, list(JournalEntry.t())}` if the journal entries are reset successfully. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.reset_journal_entries() + # {:ok, []} + # """ + # @spec reset_journal_entries(accounting_journal_server_pid()) :: {:ok, list(JournalEntry.t())} + # def reset_journal_entries(server \\ __MODULE__) do + # GenServer.call(server, :reset_journal_entries) + # end + + # @doc """ + # Returns the state of the Accounting Journal GenServer. + + # Returns `{:ok, accounting_journal_state()}` if the state is returned successfully. + + # ## Examples + + # iex> Bookkeeping.Boundary.AccountingJournal.Server.get_accounting_journal_state() + # {:ok, %{...}} + # """ + # @spec get_accounting_journal_state(accounting_journal_server_pid()) :: + # {:ok, accounting_journal_state()} + # def get_accounting_journal_state(server \\ __MODULE__) do + # GenServer.call(server, :get_accounting_journal_state) + # end + + # @impl true + # @spec init(accounting_journal_state()) :: {:ok, accounting_journal_state()} + # def init(_journal_entries) do + # AccountingJournalBackup.get() + # end + + # @impl true + # def handle_call({:create_journal_entry, params}, _from, journal_entries) do + # with {:error, :not_found} <- + # find_by_journal_entry_number(journal_entries, params.journal_entry_number), + # {:ok, journal_entry} <- + # JournalEntry.create( + # params.transaction_date, + # params.posting_date, + # params.t_accounts, + # params.journal_entry_number, + # params.reference_number, + # params.description, + # params.particulars, + # params.audit_details + # ) do + # date_details = Map.take(journal_entry.posting_date, [:year, :month, :day]) + + # updated_journal_entries = + # if journal_entries[date_details] == nil do + # Map.put(journal_entries, date_details, [journal_entry]) + # else + # updated_je_list = [journal_entry | journal_entries[date_details]] + # Map.put(journal_entries, date_details, updated_je_list) + # end + + # {:reply, {:ok, journal_entry}, updated_journal_entries, :hibernate} + # else + # {:ok, _journal_entry} -> + # {:reply, {:error, :duplicate_journal_entry_number}, journal_entries, :hibernate} + + # {:error, message} -> + # {:reply, {:error, message}, journal_entries, :hibernate} + # end + # end + + # @impl true + # def handle_call({:update_journal_entry, journal_entry, attrs}, _from, journal_entries) do + # case JournalEntry.update(journal_entry, attrs) do + # {:ok, updated_journal_entry} -> + # updated_journal_entries = + # process_journal_entry_update(journal_entries, updated_journal_entry) + + # {:reply, {:ok, updated_journal_entry}, updated_journal_entries, :hibernate} + + # {:error, message} -> + # {:reply, {:error, message}, journal_entries, :hibernate} + # end + # end + + # @impl true + # def handle_call(:all_journal_entries, from, journal_entries) do + # Task.async(fn -> + # all_entries = + # Enum.reduce(journal_entries, [], fn {_k, je_list}, acc -> je_list ++ acc end) + + # GenServer.reply(from, {:ok, all_entries}) + # end) + + # {:noreply, journal_entries} + # end + + # @impl true + # def handle_call( + # {:find_journal_entry_by_journal_entry_number, journal_entry_number}, + # from, + # journal_entries + # ) do + # Task.async(fn -> + # case find_by_journal_entry_number(journal_entries, journal_entry_number) do + # {:ok, journal_entry} -> GenServer.reply(from, {:ok, journal_entry}) + # {:error, message} -> GenServer.reply(from, {:error, message}) + # end + # end) + + # {:noreply, journal_entries} + # end + + # @impl true + # def handle_call( + # {:find_journal_entries_by_posting_date, datetime}, + # from, + # journal_entries + # ) do + # Task.async(fn -> + # case get_date_details(datetime) do + # {:ok, date_details} -> + # all_journal_entries = + # find_journal_entries_by_posting_date(journal_entries, date_details) + + # GenServer.reply(from, {:ok, all_journal_entries}) + + # {:error, message} -> + # GenServer.reply(from, {:error, message}) + # end + # end) + + # {:noreply, journal_entries} + # end + + # @impl true + # def handle_call({:find_journal_entries_by_id, id}, from, journal_entries) do + # Task.async(fn -> + # case find_by_id(journal_entries, id) do + # {:ok, journal_entry} -> GenServer.reply(from, {:ok, journal_entry}) + # {:error, message} -> GenServer.reply(from, {:error, message}) + # end + # end) + + # {:noreply, journal_entries} + # end + + # @impl true + # def handle_call( + # {:find_journal_entries_by_posting_date_range, from_datetime, to_datetime}, + # from, + # journal_entries + # ) do + # Task.async(fn -> + # with {:ok, from_date_details} <- get_date_details(from_datetime), + # {:ok, to_date_details} <- get_date_details(to_datetime) do + # je_list = + # find_journal_entries_by_date_range(journal_entries, from_date_details, to_date_details) + + # GenServer.reply(from, {:ok, je_list}) + # else + # {:error, message} -> GenServer.reply(from, {:error, message}) + # end + # end) + + # {:noreply, journal_entries} + # end + + # @impl true + # def handle_call(:reset_journal_entries, _from, _journal_entries) do + # AccountingJournalBackup.update(%{}) + # {:reply, {:ok, []}, %{}} + # end + + # @impl true + # def handle_call(:get_accounting_journal_state, from, journal_entries) do + # Task.async(fn -> GenServer.reply(from, {:ok, journal_entries}) end) + # {:noreply, journal_entries} + # end + + # @impl true + # def handle_info(_msg, state) do + # {:noreply, state} + # end + + # @impl true + # def terminate(_reason, journal_entries) do + # AccountingJournalBackup.update(journal_entries) + # end + + # defp get_date_details(datetime) when is_struct(datetime, DateTime), + # do: {:ok, Map.take(datetime, [:year, :month, :day])} + + # defp get_date_details(%{year: year, month: month, day: day}), + # do: {:ok, %{year: year, month: month, day: day}} + + # defp get_date_details(_), do: {:error, :invalid_date} + + # defp find_journal_entries_by_posting_date(journal_entries, date_details) do + # tdd_keys = Map.keys(date_details) + + # journal_entries + # |> Task.async_stream(fn {k, je} -> + # if Map.take(k, tdd_keys) == date_details, do: je, else: nil + # end) + # |> Enum.reduce([], fn {:ok, je_list}, acc -> + # if is_list(je_list), do: je_list ++ acc, else: acc + # end) + # end + + # defp find_journal_entries_by_date_range(journal_entries, from_date_details, to_date_details) do + # from_datetime = convert_date_details_to_datetime(from_date_details, "from") + # to_datetime = convert_date_details_to_datetime(to_date_details, "to") + + # journal_entries + # |> Task.async_stream(fn {k, je_list} -> + # k_datetime = convert_date_details_to_datetime(k, "from") + + # if DateTime.compare(to_datetime, k_datetime) == :gt and + # DateTime.compare(k_datetime, from_datetime) in [:gt, :eq], + # do: je_list, + # else: nil + # end) + # |> Enum.reduce([], fn {:ok, je_list}, acc -> + # if is_list(je_list), do: je_list ++ acc, else: acc + # end) + # end + + # defp find_by_journal_entry_number(journal_entries, journal_entry_number) + # when is_binary(journal_entry_number) do + # journal_entry_found = + # journal_entries + # |> Task.async_stream(fn {_k, je_list} -> + # Enum.find(je_list, &(&1.journal_entry_number == journal_entry_number)) + # end) + # |> Enum.reduce(nil, fn {:ok, search_result}, acc -> + # if is_struct(search_result, JournalEntry), do: search_result, else: acc + # end) + + # if is_map(journal_entry_found), + # do: {:ok, journal_entry_found}, + # else: {:error, :not_found} + # end + + # defp find_by_journal_entry_number(_journal_entries, _journal_entry_number), + # do: {:error, :invalid_journal_entry_number} + + # defp find_by_id(journal_entries, id) when is_binary(id) do + # journal_entry_found = + # journal_entries + # |> Task.async_stream(fn {_k, je_list} -> Enum.find(je_list, &(&1.id == id)) end) + # |> Enum.reduce(nil, fn {:ok, search_result}, acc -> + # if is_struct(search_result, JournalEntry), do: search_result, else: acc + # end) + + # if is_map(journal_entry_found), + # do: {:ok, journal_entry_found}, + # else: {:error, :not_found} + # end + + # defp find_by_id(_journal_entries, _id), do: {:error, :invalid_id} + + # defp process_journal_entry_update(journal_entries, updated_journal_entry) do + # date_details = + # Map.take(updated_journal_entry.posting_date, [:year, :month, :day]) + + # if journal_entries[date_details] == nil do + # with {:ok, old_journal_entry} <- find_by_id(journal_entries, updated_journal_entry.id), + # {:ok, old_date_details} <- + # get_date_details(old_journal_entry.posting_date) do + # updated_je_list = + # remove_journal_entry_by_id( + # journal_entries, + # old_date_details, + # old_journal_entry.id + # ) + + # journal_entries + # |> Map.put(date_details, [updated_journal_entry]) + # |> Map.put(old_date_details, updated_je_list) + # end + # else + # updated_je_list = + # update_journal_entry_by_id( + # journal_entries, + # date_details, + # updated_journal_entry + # ) + + # Map.put(journal_entries, date_details, updated_je_list) + # end + # end + + # defp remove_journal_entry_by_id( + # journal_entries, + # date_details, + # journal_entry_id + # ) do + # Enum.filter(journal_entries[date_details], fn je -> + # je.id != journal_entry_id + # end) + # end + + # defp update_journal_entry_by_id( + # journal_entries, + # date_details, + # updated_journal_entry + # ) do + # Enum.map(journal_entries[date_details], fn je -> + # if je.id == updated_journal_entry.id, do: updated_journal_entry, else: je + # end) + # end + + # defp create_journal_record(server, params) do + # t_accounts = params |> Map.get(:t_accounts, %{}) |> update_t_accounts() + # updated_params = Map.put(params, :t_accounts, t_accounts) + + # GenServer.call(server, {:create_journal_entry, updated_params}) + # end + + # defp update_t_accounts(t_accounts) do + # %{ + # left: update_account_amount_pair(t_accounts.left), + # right: update_account_amount_pair(t_accounts.right) + # } + # end + + # defp update_account_amount_pair(account_amount_pairs) do + # Enum.reduce(account_amount_pairs, [], fn t_account, acc -> + # case ChartOfAccountsServer.find_account_by_name(t_account.account) do + # {:ok, account} -> acc ++ [Map.put(t_account, :account, account)] + # _ -> acc ++ [t_account] + # end + # end) + # end + + # defp bulk_create_journal_entries(server, csv) when is_list(csv) and csv != [] do + # with %{ok: ok_create_params, error: []} <- generate_bulk_create_params(csv), + # {:ok, result} <- bulk_create_je_records(server, ok_create_params) do + # {:ok, result} + # else + # %{ok: _ok_create_params, error: errors} -> + # {:error, %{message: :invalid_csv, errors: errors}} + + # {:error, result} -> + # {:error, result} + # end + # end + + # defp bulk_create_journal_entries(_server, _csv), do: {:error, :invalid_file} + + # defp bulk_create_je_records(server, create_params_list) do + # result = + # Enum.reduce(create_params_list, %{ok: [], error: []}, fn params, acc -> + # case create_journal_record(server, params) do + # {:ok, journal_entry} -> + # Map.put(acc, :ok, [journal_entry | acc.ok]) + + # {:error, message} -> + # errors = + # acc.error ++ [%{document_number: params.journal_entry_number, error: message}] + + # Map.put(acc, :error, errors) + # end + # end) + + # if result.ok == [], do: {:error, result}, else: {:ok, result} + # end + + # defp generate_bulk_create_params(csv) do + # Enum.reduce(csv, %{ok: [], error: []}, fn csv_item, acc -> + # journal_entry_number = Map.get(csv_item, "Journal Entry Number", "") + # reference_number = Map.get(csv_item, "Transaction Reference Number", "") + # csv_posted = Map.get(csv_item, "Posted", "no") + # description = Map.get(csv_item, "Journal Entry Description", "") + # particulars = Map.get(csv_item, "Journal Entry Details", "{}") + # audit_details = Map.get(csv_item, "Audit Details", "{}") + # description = Map.get(csv_item, "Line Item Description", "") + + # posted_field = csv_posted |> String.trim() |> String.downcase() + # posted = if posted_field == "yes", do: true, else: false + + # updated_description = + # generate_updated_journal_description( + # acc.ok, + # journal_entry_number, + # description + # ) + + # with true <- + # validate_csv_items( + # journal_entry_number, + # reference_number, + # description, + # particulars, + # audit_details, + # csv_posted, + # description + # ), + # {:ok, transaction_date} <- parse_date(csv_item, "Transaction Date"), + # {:ok, posting_date} <- + # parse_date(csv_item, "General Ledger Posting Date"), + # {:ok, particulars} <- Jason.decode(particulars), + # {:ok, audit_details} <- Jason.decode(audit_details) do + # initial_params = %{ + # t_accounts: %{left: [], right: []}, + # posted: posted, + # document_number: journal_entry_number, + # reference_number: reference_number, + # description: updated_description, + # description: description, + # transaction_date: transaction_date, + # posting_date: posting_date, + # particulars: particulars, + # audit_details: audit_details + # } + + # oks = + # update_ok_params( + # acc.ok, + # csv_item, + # initial_params, + # journal_entry_number + # ) + + # Map.put(acc, :ok, oks) + # else + # {:error, error} -> + # errors = acc.error ++ [%{document_number: journal_entry_number, error: error}] + # Map.put(acc, :error, errors) + + # _error -> + # errors = + # acc.error ++ [%{document_number: journal_entry_number, error: :invalid_csv_item}] + + # Map.put(acc, :error, errors) + # end + # end) + # end + + # defp validate_csv_items( + # journal_entry_number, + # reference_number, + # description, + # particulars, + # audit_details, + # csv_posted, + # description + # ) do + # is_binary(journal_entry_number) and journal_entry_number != "" and + # is_binary(reference_number) and is_binary(description) and + # is_binary(particulars) and is_binary(audit_details) and is_binary(csv_posted) and + # is_binary(description) + # end + + # defp generate_updated_journal_description( + # acc_ok_params, + # journal_entry_number, + # description + # ) do + # existing_description = + # acc_ok_params + # |> Enum.find(%{}, fn param -> param.journal_entry_number == journal_entry_number end) + # |> Map.get(:description, "") + + # cond do + # existing_description == "" -> + # description + + # existing_description != "" and description == "" -> + # existing_description + + # existing_description != "" and description != "" -> + # existing_description <> " " <> description + # end + # end + + # defp update_ok_params( + # ok_params, + # csv_item, + # initial_params, + # journal_entry_number + # ) do + # account = Map.get(csv_item, "Name", "") + # description = Map.get(csv_item, "Line Item Description", "") + # debit = Map.get(csv_item, "Debit", "") + # credit = Map.get(csv_item, "Credit", "") + + # case Enum.find(ok_params, fn param -> param.journal_entry_number == journal_entry_number end) do + # nil -> + # updated_t_accounts = + # set_t_accounts(debit, credit, account, description, initial_params) + + # params = Map.put(initial_params, :t_accounts, updated_t_accounts) + + # ok_params ++ [params] + + # found_param -> + # updated_t_accounts = + # set_t_accounts(debit, credit, account, description, found_param) + + # Enum.map(ok_params, fn + # %{document_number: document_number} when document_number == journal_entry_number -> + # Map.put(initial_params, :t_accounts, updated_t_accounts) + + # param -> + # param + # end) + # end + # end + + # defp set_t_accounts(debit, "" = _credit, account, description, params) do + # debit_amount = if debit == "", do: "0", else: Decimal.new(debit) + + # t_accounts_debit_item = %{ + # account: account, + # amount: Decimal.new(debit_amount), + # description: description + # } + + # %{ + # left: [t_accounts_debit_item] ++ params.t_accounts.left, + # right: params.t_accounts.right + # } + # end + + # defp set_t_accounts("" = _debit, credit, account, description, params) do + # credit_amount = if credit == "", do: "0", else: Decimal.new(credit) + + # t_accounts_credit_item = %{ + # account: account, + # amount: Decimal.new(credit_amount), + # description: description + # } + + # %{ + # left: params.t_accounts.left, + # right: [t_accounts_credit_item] ++ params.t_accounts.right + # } + # end + + # defp parse_date(csv_item, date_column_header) do + # result = + # csv_item + # |> Map.get(date_column_header, "") + # |> String.split("-") + + # if length(result) == 3 do + # [month, day, year] = result + # {:ok, datetime, _} = DateTime.from_iso8601("#{year}-#{month}-#{day}T00:00:00Z") + # {:ok, datetime} + # else + # {:error, :invalid_date} + # end + # end + + # defp read_csv(path) do + # csv_inputs = + # path + # |> File.stream!() + # |> CSV.parse_stream(skip_headers: false) + # |> Stream.transform(nil, fn + # headers, nil -> {[], headers} + # row, headers -> {[Enum.zip(headers, row) |> Map.new()], headers} + # end) + # |> Enum.to_list() + + # {:ok, csv_inputs} + # end + + # defp convert_date_details_to_datetime(date_details, date_type) do + # year = Map.get(date_details, :year, 1000) + # month = Map.get(date_details, :month, 1) + # day = Map.get(date_details, :day, 1) + # time = if date_type == "from", do: "00:00:00Z", else: "23:59:59Z" + + # if is_integer(year) and is_integer(month) and is_integer(day) do + # month = day_or_month_to_string(month) + # day = day_or_month_to_string(day) + # {:ok, datetime, _} = DateTime.from_iso8601("#{year}-#{month}-#{day}T#{time}") + # datetime + # else + # {:error, :invalid_date} + # end + # end + + # defp day_or_month_to_string(day_or_month) when day_or_month < 10, do: "0#{day_or_month}" + # defp day_or_month_to_string(day_or_month), do: "#{day_or_month}" end diff --git a/lib/bookkeeping/core/journal_entry.ex b/lib/bookkeeping/core/journal_entry.ex index aaf370e..59d1056 100644 --- a/lib/bookkeeping/core/journal_entry.ex +++ b/lib/bookkeeping/core/journal_entry.ex @@ -9,12 +9,12 @@ defmodule Bookkeeping.Core.JournalEntry do @type t :: %__MODULE__{ id: UUID.t(), transaction_date: DateTime.t(), - general_ledger_posting_date: DateTime.t(), + posting_date: DateTime.t(), line_items: list(LineItem.t()), - journal_entry_number: String.t(), - transaction_reference_number: String.t(), - journal_entry_description: String.t(), - journal_entry_details: map(), + document_number: String.t(), + reference_number: String.t(), + description: String.t(), + particulars: map(), audit_logs: list(AuditLog.t()), posted: boolean() } @@ -26,11 +26,11 @@ defmodule Bookkeeping.Core.JournalEntry do defstruct id: UUID.uuid4(), transaction_date: DateTime.utc_now(), - general_ledger_posting_date: DateTime.utc_now(), - journal_entry_number: "", - transaction_reference_number: "", - journal_entry_description: "", - journal_entry_details: %{}, + posting_date: DateTime.utc_now(), + document_number: "", + reference_number: "", + description: "", + particulars: %{}, line_items: [], audit_logs: [], posted: false @@ -40,14 +40,14 @@ defmodule Bookkeeping.Core.JournalEntry do Arguments: - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) - - general_ledger_posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. + - posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. - t_accounts: The map of line items. The map must have the following keys: - left: The list of maps with account and amount field and represents the entry type of debit. - right: The list of maps with account and amount field and represents the entry type of credit. - - journal_entry_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). - - transaction_reference_number: The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) - - journal_entry_description: The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) - - journal_entry_details: The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) + - document_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). + - reference_number: The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) + - description: The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) + - particulars: The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) - audit_details: The details of the audit log. Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`, `{:error, :invalid_line_items}`, `{:error, :unbalanced_line_items}`, or `{:error, list(:invalid_amount | :invalid_account | :inactive_account)}`. @@ -81,29 +81,29 @@ defmodule Bookkeeping.Core.JournalEntry do | {:error, list(:invalid_amount | :invalid_account | :inactive_account)} def create( transaction_date, - general_ledger_posting_date, + posting_date, t_accounts, journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, + reference_number, + description, + particulars, audit_details ) do valid_fields? = - is_binary(journal_entry_number) and is_binary(transaction_reference_number) and - is_binary(journal_entry_description) and is_map(journal_entry_details) and + is_binary(journal_entry_number) and is_binary(reference_number) and + is_binary(description) and is_map(particulars) and is_map(t_accounts) and is_map(audit_details) and not is_nil(transaction_date) and - not is_nil(general_ledger_posting_date) + not is_nil(posting_date) if valid_fields? do new( transaction_date, - general_ledger_posting_date, + posting_date, t_accounts, journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, + reference_number, + description, + particulars, audit_details ) else @@ -116,7 +116,7 @@ defmodule Bookkeeping.Core.JournalEntry do Arguments: - journal_entry: The journal entry to be updated. - - attrs: The attributes to be updated. The editable attributes are `transaction_date`, `journal_entry_number`, `journal_entry_description`, `posted`, `t_accounts`, and `audit_details`. + - attrs: The attributes to be updated. The editable attributes are `transaction_date`, `journal_entry_number`, `description`, `posted`, `t_accounts`, and `audit_details`. Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`. @@ -145,16 +145,16 @@ defmodule Bookkeeping.Core.JournalEntry do update_dates_and_line_items( journal_entry, params.transaction_date, - params.general_ledger_posting_date, + params.posting_date, params.t_accounts ), {:ok, final_je_update} <- - update_other_journal_entry_details( + update_other_particulars( initial_je_update, params.journal_entry_number, - params.transaction_reference_number, - params.journal_entry_description, - params.journal_entry_details, + params.reference_number, + params.description, + params.particulars, audit_log, params.posted ) do @@ -171,12 +171,12 @@ defmodule Bookkeeping.Core.JournalEntry do defp new( transaction_date, - general_ledger_posting_date, + posting_date, t_accounts, journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, + reference_number, + description, + particulars, audit_details ) do with {:ok, line_items} <- LineItem.bulk_create(t_accounts), @@ -190,12 +190,12 @@ defmodule Bookkeeping.Core.JournalEntry do %__MODULE__{ id: UUID.uuid4(), transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, + posting_date: posting_date, line_items: line_items, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_description: journal_entry_description, - journal_entry_details: journal_entry_details, + document_number: journal_entry_number, + reference_number: reference_number, + description: description, + particulars: particulars, audit_logs: [audit_log] }} else @@ -205,26 +205,21 @@ defmodule Bookkeeping.Core.JournalEntry do defp validate_update_fields(params) do is_binary(params.journal_entry_number) and params.journal_entry_number != "" and - is_binary(params.transaction_reference_number) and - is_binary(params.journal_entry_description) and - not is_nil(params.transaction_date) and not is_nil(params.general_ledger_posting_date) and + is_binary(params.reference_number) and + is_binary(params.description) and + not is_nil(params.transaction_date) and not is_nil(params.posting_date) and is_boolean(params.posted) and is_map(params.t_accounts) and is_map(params.audit_details) end defp validate_update_params(journal_entry, attrs) do params = %{ transaction_date: Map.get(attrs, :transaction_date, journal_entry.transaction_date), - general_ledger_posting_date: - Map.get(attrs, :general_ledger_posting_date, journal_entry.general_ledger_posting_date), + posting_date: Map.get(attrs, :posting_date, journal_entry.posting_date), t_accounts: Map.get(attrs, :t_accounts, %{left: [], right: []}), - journal_entry_number: - Map.get(attrs, :journal_entry_number, journal_entry.journal_entry_number), - transaction_reference_number: - Map.get(attrs, :transaction_reference_number, journal_entry.transaction_reference_number), - journal_entry_description: - Map.get(attrs, :journal_entry_description, journal_entry.journal_entry_description), - journal_entry_details: - Map.get(attrs, :journal_entry_details, journal_entry.journal_entry_details), + document_number: Map.get(attrs, :journal_entry_number, journal_entry.journal_entry_number), + reference_number: Map.get(attrs, :reference_number, journal_entry.reference_number), + description: Map.get(attrs, :description, journal_entry.description), + particulars: Map.get(attrs, :particulars, journal_entry.particulars), posted: Map.get(attrs, :posted, journal_entry.posted), audit_details: Map.get(attrs, :audit_details, %{}) } @@ -237,7 +232,7 @@ defmodule Bookkeeping.Core.JournalEntry do defp update_dates_and_line_items( journal_entry, transaction_date, - general_ledger_posting_date, + posting_date, t_accounts ) do line_items = @@ -250,29 +245,29 @@ defmodule Bookkeeping.Core.JournalEntry do update_params = %{ transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, + posting_date: posting_date, line_items: line_items } {:ok, Map.merge(journal_entry, update_params)} end - defp update_other_journal_entry_details( + defp update_other_particulars( journal_entry, journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, + reference_number, + description, + particulars, audit_log, posted ) do existing_audit_logs = Map.get(journal_entry, :audit_logs, []) update_params = %{ - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_description: journal_entry_description, - journal_entry_details: journal_entry_details, + document_number: journal_entry_number, + reference_number: reference_number, + description: description, + particulars: particulars, audit_logs: [audit_log | existing_audit_logs], posted: posted } diff --git a/test/bookkeeping/boundary/accounting_journal_test.exs b/test/bookkeeping/boundary/accounting_journal_test.exs index d660dce..06df4c4 100644 --- a/test/bookkeeping/boundary/accounting_journal_test.exs +++ b/test/bookkeeping/boundary/accounting_journal_test.exs @@ -7,10 +7,10 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do setup do transaction_date = DateTime.utc_now() - general_ledger_posting_date = DateTime.utc_now() + posting_date = DateTime.utc_now() journal_entry_number = "JE100100" - transaction_reference_number = "INV100100" - journal_entry_description = "journal entry description" + reference_number = "INV100100" + description = "journal entry description" audit_details = %{email: "example@example.com"} {:ok, cash_account} = @@ -53,30 +53,30 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do ] } - journal_entry_details = %{approved_by: "John Doe", approved_at: DateTime.utc_now()} + particulars = %{approved_by: "John Doe", approved_at: DateTime.utc_now()} create_je_params = %{ transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, + posting_date: posting_date, t_accounts: t_accounts, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_description: journal_entry_description, - journal_entry_details: journal_entry_details, + document_number: journal_entry_number, + reference_number: reference_number, + description: description, + particulars: particulars, audit_details: audit_details } {:ok, transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, + posting_date: posting_date, + document_number: journal_entry_number, + reference_number: reference_number, t_accounts: t_accounts, cash_account: cash_account, revenue_account: revenue_account, inactive_revenue_account: inactive_revenue_account, - journal_entry_description: journal_entry_description, - journal_entry_details: journal_entry_details, + description: description, + particulars: particulars, audit_details: audit_details, create_je_params: create_je_params} end @@ -100,7 +100,7 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do AccountingJournalServer.create_journal_entry(params) assert journal_entry_1.journal_entry_number == "ref_num_1" - assert journal_entry_1.journal_entry_description == "journal entry description" + assert journal_entry_1.description == "journal entry description" assert journal_entry_1.line_items |> length() == 2 assert journal_entry_1.audit_logs assert journal_entry_1.posted == false @@ -114,20 +114,20 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do assert {:ok, journal_entry_3} = AccountingJournalServer.create_journal_entry(params) - other_transaction_date = DateTime.add(journal_entry_2.general_ledger_posting_date, 10, :day) + other_transaction_date = DateTime.add(journal_entry_2.posting_date, 10, :day) params = create_je_params |> Map.put(:transaction_date, other_transaction_date) - |> Map.put(:general_ledger_posting_date, other_transaction_date) + |> Map.put(:posting_date, other_transaction_date) |> Map.put(:journal_entry_number, "ref_num_4") assert {:ok, journal_entry_4} = AccountingJournalServer.create_journal_entry(params) assert {:ok, found_journal_entries} = - journal_entry_2.general_ledger_posting_date - |> AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date() + journal_entry_2.posting_date + |> AccountingJournalServer.find_journal_entries_by_posting_date() assert Enum.member?(found_journal_entries, journal_entry_2) assert Enum.member?(found_journal_entries, journal_entry_3) @@ -176,7 +176,7 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do params = create_je_params - |> Map.put(:journal_entry_description, nil) + |> Map.put(:description, nil) |> Map.put(:journal_entry_number, "invalid_je_1") assert {:error, :invalid_journal_entry} = @@ -243,20 +243,20 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do AccountingJournalServer.create_journal_entry(params) assert {:ok, je_result_1} = - journal_entry_1.general_ledger_posting_date - |> AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date() + journal_entry_1.posting_date + |> AccountingJournalServer.find_journal_entries_by_posting_date() assert is_list(je_result_1) assert {:ok, je_result_2} = - journal_entry_1.general_ledger_posting_date + journal_entry_1.posting_date |> Map.take([:year, :month, :day]) - |> AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date() + |> AccountingJournalServer.find_journal_entries_by_posting_date() assert is_list(je_result_2) assert {:error, :invalid_date} = - AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date(nil) + AccountingJournalServer.find_journal_entries_by_posting_date(nil) end test "find journal entries by reference number", %{create_je_params: create_je_params} do @@ -311,17 +311,17 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do AccountingJournalServer.create_journal_entry(params) current_from_date_details = - journal_entry_1.general_ledger_posting_date + journal_entry_1.posting_date |> DateTime.add(-10, :day) |> Map.take([:year, :month, :day]) current_to_date_details = - journal_entry_2.general_ledger_posting_date + journal_entry_2.posting_date |> DateTime.add(10, :day) |> Map.take([:year, :month, :day]) assert {:ok, journal_entries} = - AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date_range( + AccountingJournalServer.find_journal_entries_by_posting_date_range( current_from_date_details, current_to_date_details ) @@ -331,24 +331,24 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do assert Enum.member?(journal_entries, journal_entry_2) assert {:error, :invalid_date} = - AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date_range( + AccountingJournalServer.find_journal_entries_by_posting_date_range( nil, - journal_entry_1.general_ledger_posting_date + journal_entry_1.posting_date ) assert {:error, :invalid_date} = - AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date_range( - journal_entry_1.general_ledger_posting_date, + AccountingJournalServer.find_journal_entries_by_posting_date_range( + journal_entry_1.posting_date, nil ) from_date_details = - Map.take(journal_entry_1.general_ledger_posting_date, [:year, :month, :day]) + Map.take(journal_entry_1.posting_date, [:year, :month, :day]) - to_date_details = Map.take(journal_entry_2.general_ledger_posting_date, [:year, :month, :day]) + to_date_details = Map.take(journal_entry_2.posting_date, [:year, :month, :day]) assert {:ok, journal_entries} = - AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date_range( + AccountingJournalServer.find_journal_entries_by_posting_date_range( from_date_details, to_date_details ) @@ -358,33 +358,33 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do assert Enum.member?(journal_entries, journal_entry_2) past_from_date_details = - journal_entry_1.general_ledger_posting_date + journal_entry_1.posting_date |> DateTime.add(-100, :day) |> Map.take([:year, :month, :day]) past_to_date_details = - journal_entry_2.general_ledger_posting_date + journal_entry_2.posting_date |> DateTime.add(-50, :day) |> Map.take([:year, :month, :day]) assert {:ok, []} = - AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date_range( + AccountingJournalServer.find_journal_entries_by_posting_date_range( past_from_date_details, past_to_date_details ) future_from_date_details = - journal_entry_1.general_ledger_posting_date + journal_entry_1.posting_date |> DateTime.add(100, :day) |> Map.take([:year, :month, :day]) future_to_date_details = - journal_entry_2.general_ledger_posting_date + journal_entry_2.posting_date |> DateTime.add(150, :day) |> Map.take([:year, :month, :day]) assert {:ok, []} = - AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date_range( + AccountingJournalServer.find_journal_entries_by_posting_date_range( future_from_date_details, future_to_date_details ) @@ -404,15 +404,15 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do "../../../../test/bookkeeping/data/valid_journal_entries.csv" ) - journal_entry_descriptions = - Enum.map(created_journals, fn journal_entry -> journal_entry.journal_entry_description end) + descriptions = + Enum.map(created_journals, fn journal_entry -> journal_entry.description end) assert Enum.member?( - journal_entry_descriptions, + descriptions, "JE_1001_INV JE_1001_AP JE_1001_LTD JE_1001_STD JE_1001_C" ) - assert Enum.member?(journal_entry_descriptions, "JE_1007_INV") + assert Enum.member?(descriptions, "JE_1007_INV") descriptions = created_journals @@ -433,8 +433,8 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do ) assert errors == [ - %{error: :duplicate_journal_entry_number, journal_entry_number: "1001"}, - %{error: :duplicate_journal_entry_number, journal_entry_number: "1007"} + %{error: :duplicate_journal_entry_number, document_number: "1001"}, + %{error: :duplicate_journal_entry_number, document_number: "1007"} ] # importing a file with invalid journal entries @@ -444,10 +444,10 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do ) assert errors == [ - %{error: :invalid_date, journal_entry_number: "1003"}, - %{error: :invalid_csv_item, journal_entry_number: ""}, - %{error: :invalid_date, journal_entry_number: "1003"}, - %{error: :invalid_date, journal_entry_number: "1005"} + %{error: :invalid_date, document_number: "1003"}, + %{error: :invalid_csv_item, document_number: ""}, + %{error: :invalid_date, document_number: "1003"}, + %{error: :invalid_date, document_number: "1005"} ] # importing a missing file @@ -468,7 +468,7 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do "../../../../test/bookkeeping/data/partially_valid_journal_entries.csv" ) - assert errors == [%{error: :unbalanced_line_items, journal_entry_number: "1009"}] + assert errors == [%{error: :unbalanced_line_items, document_number: "1009"}] assert Enum.count(oks) == 1 end @@ -487,13 +487,13 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do additional_random_days = Enum.random(10..100) - updated_general_ledger_posting_date = - DateTime.add(journal_entry.general_ledger_posting_date, additional_random_days, :day) + updated_posting_date = + DateTime.add(journal_entry.posting_date, additional_random_days, :day) assert {:ok, updated_journal_entry} = AccountingJournalServer.update_journal_entry(journal_entry, %{ - general_ledger_posting_date: updated_general_ledger_posting_date, - journal_entry_description: "second updated description", + posting_date: updated_posting_date, + description: "second updated description", posted: false, t_accounts: %{ left: [%{account: revenue_account, amount: Decimal.new(200)}], @@ -503,18 +503,18 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do assert updated_journal_entry.id == journal_entry.id - refute updated_journal_entry.general_ledger_posting_date == - journal_entry.general_ledger_posting_date + refute updated_journal_entry.posting_date == + journal_entry.posting_date assert updated_journal_entry.journal_entry_number == journal_entry.journal_entry_number - assert updated_journal_entry.journal_entry_description == "second updated description" + assert updated_journal_entry.description == "second updated description" assert updated_journal_entry.posted == false assert updated_journal_entry.line_items |> length() == 2 assert updated_journal_entry.audit_logs assert {:ok, third_journal_entry_update} = AccountingJournalServer.update_journal_entry(updated_journal_entry, %{ - journal_entry_description: "third updated description", + description: "third updated description", posted: true, t_accounts: %{ left: [%{account: revenue_account, amount: Decimal.new(300)}], @@ -524,18 +524,18 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do assert third_journal_entry_update.id == journal_entry.id - refute third_journal_entry_update.general_ledger_posting_date == - journal_entry.general_ledger_posting_date + refute third_journal_entry_update.posting_date == + journal_entry.posting_date assert third_journal_entry_update.journal_entry_number == journal_entry.journal_entry_number - assert third_journal_entry_update.journal_entry_description == "third updated description" + assert third_journal_entry_update.description == "third updated description" assert third_journal_entry_update.posted == true assert third_journal_entry_update.line_items |> length() == 2 assert third_journal_entry_update.audit_logs assert {:error, :already_posted_journal_entry} = AccountingJournalServer.update_journal_entry(third_journal_entry_update, %{ - journal_entry_description: "fourth updated description", + description: "fourth updated description", posted: false, t_accounts: %{ left: [%{account: revenue_account, amount: Decimal.new(400)}], @@ -544,16 +544,16 @@ defmodule Bookkeeping.Boundary.AccountingJournalTest do }) assert {:ok, journal_entries} = - journal_entry.general_ledger_posting_date - |> AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date() + journal_entry.posting_date + |> AccountingJournalServer.find_journal_entries_by_posting_date() refute Enum.member?(journal_entries, journal_entry) refute Enum.member?(journal_entries, updated_journal_entry) refute Enum.member?(journal_entries, third_journal_entry_update) assert {:ok, journal_entries} = - updated_journal_entry.general_ledger_posting_date - |> AccountingJournalServer.find_journal_entries_by_general_ledger_posting_date() + updated_journal_entry.posting_date + |> AccountingJournalServer.find_journal_entries_by_posting_date() refute Enum.member?(journal_entries, journal_entry) refute Enum.member?(journal_entries, updated_journal_entry) diff --git a/test/bookkeeping/core/journal_entry_test.exs b/test/bookkeeping/core/journal_entry_test.exs index ba0ac12..9c8ffea 100644 --- a/test/bookkeeping/core/journal_entry_test.exs +++ b/test/bookkeeping/core/journal_entry_test.exs @@ -4,20 +4,20 @@ defmodule Bookkeeping.Core.JournalEntryTest do setup do transaction_date = DateTime.utc_now() - general_ledger_posting_date = DateTime.utc_now() + posting_date = DateTime.utc_now() journal_entry_number = "JE100100" - transaction_reference_number = "INV100100" + reference_number = "INV100100" audit_details = %{created_by: "example@example.com"} {:ok, asset_account} = - Account.create("10000", "cash", "asset", "journal_entry_description", audit_details) + Account.create("10000", "cash", "asset", "description", audit_details) {:ok, revenue_account} = Account.create( "20000", "service revenue", "revenue", - "journal_entry_description", + "description", audit_details ) @@ -38,149 +38,149 @@ defmodule Bookkeeping.Core.JournalEntryTest do ] } - journal_entry_details = %{approved_by: "example@example.com"} + particulars = %{approved_by: "example@example.com"} {:ok, transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, + posting_date: posting_date, asset_account: asset_account, revenue_account: revenue_account, t_accounts: t_accounts, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_details: journal_entry_details, + document_number: journal_entry_number, + reference_number: reference_number, + particulars: particulars, audit_details: audit_details} end test "create a journal entry", %{ transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, + posting_date: posting_date, t_accounts: t_accounts, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_details: journal_entry_details, + document_number: journal_entry_number, + reference_number: reference_number, + particulars: particulars, audit_details: audit_details } do assert {:ok, _journal_entry} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, t_accounts, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) end test "disallow journal entry with invalid t_accounts", %{ transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, + posting_date: posting_date, asset_account: asset_account, revenue_account: revenue_account, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_details: journal_entry_details, + document_number: journal_entry_number, + reference_number: reference_number, + particulars: particulars, audit_details: audit_details } do assert {:error, [:invalid_account]} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, %{ left: [%{account: "revenue_account", amount: Decimal.new(100)}], right: [%{account: asset_account, amount: Decimal.new(100)}] }, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) assert {:error, [:invalid_account]} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, %{ left: [%{account: revenue_account, amount: Decimal.new(100)}], right: [%{account: "asset_account", amount: Decimal.new(100)}] }, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) assert {:error, :unbalanced_line_items} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, %{ left: [%{account: revenue_account, amount: Decimal.new(100)}], right: [%{account: asset_account, amount: Decimal.new(200)}] }, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) assert {:error, [:invalid_amount]} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, %{ left: [%{account: revenue_account, amount: 100}], right: [%{account: asset_account, amount: Decimal.new(200)}] }, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) assert {:error, [:invalid_amount]} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, %{ left: [%{account: revenue_account, amount: Decimal.new(200)}], right: [%{account: asset_account, amount: 200}] }, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) assert {:error, [:invalid_amount]} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, %{ left: [%{account: revenue_account, amount: 100}], right: [%{account: asset_account, amount: Decimal.new(200)}] }, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) end test "disallow journal entry with invalid fields", %{ transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_details: journal_entry_details, + posting_date: posting_date, + document_number: journal_entry_number, + reference_number: reference_number, + particulars: particulars, audit_details: audit_details } do assert {:error, :invalid_journal_entry} = @@ -189,45 +189,45 @@ defmodule Bookkeeping.Core.JournalEntryTest do nil, %{}, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) assert {:error, :invalid_line_items} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, %{}, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) end test "update journal entry", %{ transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_posting_date, + posting_date: posting_date, t_accounts: t_accounts, asset_account: asset_account, revenue_account: revenue_account, - journal_entry_number: journal_entry_number, - transaction_reference_number: transaction_reference_number, - journal_entry_details: journal_entry_details, + document_number: journal_entry_number, + reference_number: reference_number, + particulars: particulars, audit_details: audit_details } do assert {:ok, journal_entry} = JournalEntry.create( transaction_date, - general_ledger_posting_date, + posting_date, t_accounts, journal_entry_number, - transaction_reference_number, + reference_number, "journal entry description", - journal_entry_details, + particulars, audit_details ) @@ -235,8 +235,8 @@ defmodule Bookkeeping.Core.JournalEntryTest do assert {:ok, updated_journal_entry} = JournalEntry.update(journal_entry, %{ - journal_entry_description: "second updated description", - journal_entry_details: %{approved_by: "other_example@example.com"}, + description: "second updated description", + particulars: %{approved_by: "other_example@example.com"}, posted: false, t_accounts: %{ left: [%{account: asset_account, amount: Decimal.new(200)}], @@ -244,33 +244,33 @@ defmodule Bookkeeping.Core.JournalEntryTest do } }) - assert updated_journal_entry.general_ledger_posting_date == - journal_entry.general_ledger_posting_date + assert updated_journal_entry.posting_date == + journal_entry.posting_date assert updated_journal_entry.journal_entry_number == journal_entry.journal_entry_number - refute updated_journal_entry.journal_entry_description == - journal_entry.journal_entry_description + refute updated_journal_entry.description == + journal_entry.description assert {:ok, updated_journal_entry} = JournalEntry.update(journal_entry, %{ - journal_entry_description: "updated description", + description: "updated description", posted: true }) - assert updated_journal_entry.general_ledger_posting_date == - journal_entry.general_ledger_posting_date + assert updated_journal_entry.posting_date == + journal_entry.posting_date assert updated_journal_entry.journal_entry_number == journal_entry.journal_entry_number - refute updated_journal_entry.journal_entry_description == - journal_entry.journal_entry_description + refute updated_journal_entry.description == + journal_entry.description refute updated_journal_entry.posted == journal_entry.posted assert {:error, :already_posted_journal_entry} = JournalEntry.update(updated_journal_entry, %{ - journal_entry_description: "third description update", + description: "third description update", posted: true, t_accounts: %{ left: [%{account: asset_account, amount: Decimal.new(200)}], From ea33fdaa7dbc4b2b941df8a98094dcc07f089df4 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Fri, 22 Dec 2023 00:27:58 +0800 Subject: [PATCH 28/32] remove uuid dependency --- lib/bookkeeping/core/audit_log.ex | 4 +- lib/bookkeeping/core/journal_entry.ex | 438 ++++++++-------- mix.exs | 1 - mix.lock | 5 +- test/bookkeeping/core/journal_entry_test.exs | 500 +++++++++---------- 5 files changed, 470 insertions(+), 478 deletions(-) diff --git a/lib/bookkeeping/core/audit_log.ex b/lib/bookkeeping/core/audit_log.ex index 25396d7..272bb7b 100644 --- a/lib/bookkeeping/core/audit_log.ex +++ b/lib/bookkeeping/core/audit_log.ex @@ -11,7 +11,6 @@ defmodule Bookkeeping.Core.AuditLog do t type is a struct that represents an audit log. """ @type t :: %__MODULE__{ - id: UUID.t(), record_type: String.t(), action_type: String.t(), details: map(), @@ -29,8 +28,7 @@ defmodule Bookkeeping.Core.AuditLog do audit_details: map() } - defstruct id: UUID.uuid4(), - record_type: "", + defstruct record_type: "", action_type: "", details: %{}, created_at: nil, diff --git a/lib/bookkeeping/core/journal_entry.ex b/lib/bookkeeping/core/journal_entry.ex index 59d1056..845b7dc 100644 --- a/lib/bookkeeping/core/journal_entry.ex +++ b/lib/bookkeeping/core/journal_entry.ex @@ -7,7 +7,6 @@ defmodule Bookkeeping.Core.JournalEntry do alias Bookkeeping.Core.{AuditLog, LineItem} @type t :: %__MODULE__{ - id: UUID.t(), transaction_date: DateTime.t(), posting_date: DateTime.t(), line_items: list(LineItem.t()), @@ -19,13 +18,7 @@ defmodule Bookkeeping.Core.JournalEntry do posted: boolean() } - @type t_accounts :: %{ - left: list(LineItem.t()), - right: list(LineItem.t()) - } - - defstruct id: UUID.uuid4(), - transaction_date: DateTime.utc_now(), + defstruct transaction_date: DateTime.utc_now(), posting_date: DateTime.utc_now(), document_number: "", reference_number: "", @@ -35,243 +28,246 @@ defmodule Bookkeeping.Core.JournalEntry do audit_logs: [], posted: false - @doc """ - Creates a new journal entry struct. + # @type t_accounts :: %{ + # left: list(LineItem.t()), + # right: list(LineItem.t()) + # } + # @doc """ + # Creates a new journal entry struct. - Arguments: - - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) - - posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. - - t_accounts: The map of line items. The map must have the following keys: - - left: The list of maps with account and amount field and represents the entry type of debit. - - right: The list of maps with account and amount field and represents the entry type of credit. - - document_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). - - reference_number: The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) - - description: The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) - - particulars: The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) - - audit_details: The details of the audit log. + # Arguments: + # - transaction_date: The date of the transaction. This is usually the date of the source document (i.e. invoice date, check date, etc.) + # - posting_date: The date of the General Ledger posting. This is usually the date when the journal entry is posted to the General Ledger. + # - t_accounts: The map of line items. The map must have the following keys: + # - left: The list of maps with account and amount field and represents the entry type of debit. + # - right: The list of maps with account and amount field and represents the entry type of credit. + # - document_number: The unique reference number of the journal entry. This is an auto-generated unique sequential identifier that is distinct from the transaction reference number (i.e. JE001000, JE001002, etc). + # - reference_number: The reference number of the transaction. This is usually the reference number of the source document (i.e. invoice number, check number, etc.) + # - description: The description of the journal entry. This is usually the description of the source document (i.e. invoice description, check description, etc.) + # - particulars: The details of the journal entry. The details are usually the details of the source document (i.e. invoice details, check details, etc.) + # - audit_details: The details of the audit log. - Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`, `{:error, :invalid_line_items}`, `{:error, :unbalanced_line_items}`, or `{:error, list(:invalid_amount | :invalid_account | :inactive_account)}`. + # Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`, `{:error, :invalid_line_items}`, `{:error, :unbalanced_line_items}`, or `{:error, list(:invalid_amount | :invalid_account | :inactive_account)}`. - ## Examples + # ## Examples - iex> JournalEntry.create(DateTime.utc_now(), DateTime.utc_now(), %{ - left: [%{account: asset_account, amount: Decimal.new(100), description: ""}], - right: [%{account: revenue_account, amount: Decimal.new(100), description: ""}] - }, "JE001001", "INV001001", "description", %{}, %{}) - {:ok, %JournalEntry{...}} + # iex> JournalEntry.create(DateTime.utc_now(), DateTime.utc_now(), %{ + # left: [%{account: asset_account, amount: Decimal.new(100), description: ""}], + # right: [%{account: revenue_account, amount: Decimal.new(100), description: ""}] + # }, "JE001001", "INV001001", "description", %{}, %{}) + # {:ok, %JournalEntry{...}} - iex> JournalEntry.create(DateTime.utc_now(), "reference number", "description", %{}, %{}) - {:error, :invalid_journal_entry} + # iex> JournalEntry.create(DateTime.utc_now(), "reference number", "description", %{}, %{}) + # {:error, :invalid_journal_entry} - """ - @spec create( - DateTime.t(), - DateTime.t(), - t_accounts(), - String.t(), - String.t(), - String.t(), - map(), - map() - ) :: - {:ok, __MODULE__.t()} - | {:error, :invalid_journal_entry} - | {:error, :unbalanced_line_items} - | {:error, :invalid_line_items} - | {:error, list(:invalid_amount | :invalid_account | :inactive_account)} - def create( - transaction_date, - posting_date, - t_accounts, - journal_entry_number, - reference_number, - description, - particulars, - audit_details - ) do - valid_fields? = - is_binary(journal_entry_number) and is_binary(reference_number) and - is_binary(description) and is_map(particulars) and - is_map(t_accounts) and is_map(audit_details) and not is_nil(transaction_date) and - not is_nil(posting_date) + # """ + # @spec create( + # DateTime.t(), + # DateTime.t(), + # t_accounts(), + # String.t(), + # String.t(), + # String.t(), + # map(), + # map() + # ) :: + # {:ok, __MODULE__.t()} + # | {:error, :invalid_journal_entry} + # | {:error, :unbalanced_line_items} + # | {:error, :invalid_line_items} + # | {:error, list(:invalid_amount | :invalid_account | :inactive_account)} + # def create( + # transaction_date, + # posting_date, + # t_accounts, + # journal_entry_number, + # reference_number, + # description, + # particulars, + # audit_details + # ) do + # valid_fields? = + # is_binary(journal_entry_number) and is_binary(reference_number) and + # is_binary(description) and is_map(particulars) and + # is_map(t_accounts) and is_map(audit_details) and not is_nil(transaction_date) and + # not is_nil(posting_date) - if valid_fields? do - new( - transaction_date, - posting_date, - t_accounts, - journal_entry_number, - reference_number, - description, - particulars, - audit_details - ) - else - {:error, :invalid_journal_entry} - end - end + # if valid_fields? do + # new( + # transaction_date, + # posting_date, + # t_accounts, + # journal_entry_number, + # reference_number, + # description, + # particulars, + # audit_details + # ) + # else + # {:error, :invalid_journal_entry} + # end + # end - @doc """ - Updates a journal entry struct. Update can only be done if the journal entry is not posted. + # @doc """ + # Updates a journal entry struct. Update can only be done if the journal entry is not posted. - Arguments: - - journal_entry: The journal entry to be updated. - - attrs: The attributes to be updated. The editable attributes are `transaction_date`, `journal_entry_number`, `description`, `posted`, `t_accounts`, and `audit_details`. + # Arguments: + # - journal_entry: The journal entry to be updated. + # - attrs: The attributes to be updated. The editable attributes are `transaction_date`, `journal_entry_number`, `description`, `posted`, `t_accounts`, and `audit_details`. - Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`. + # Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`. - ## Examples + # ## Examples - iex> JournalEntry.update(journal_entry, %{description: "updated description",posted: true}) - {:ok, %JournalEntry{...}} + # iex> JournalEntry.update(journal_entry, %{description: "updated description",posted: true}) + # {:ok, %JournalEntry{...}} - iex> JournalEntry.update(journal_entry, %{transaction_date: DateTime.utc_now()}) - {:error, :already_posted_journal_entry} + # iex> JournalEntry.update(journal_entry, %{transaction_date: DateTime.utc_now()}) + # {:error, :already_posted_journal_entry} - iex> JournalEntry.update(not_existing_journal_entry, %{}) - {:error, :invalid_journal_entry} - """ - @spec update(__MODULE__.t(), map()) :: {:ok, __MODULE__.t()} | {:error, :invalid_journal_entry} - def update(journal_entry, attrs) - when is_map(attrs) and map_size(attrs) > 0 and journal_entry.posted == false do - with {:ok, params} <- validate_update_params(journal_entry, attrs), - {:ok, audit_log} <- - AuditLog.create(%{ - record_type: "journal_entry", - action_type: "update", - audit_details: params.audit_details - }), - {:ok, initial_je_update} <- - update_dates_and_line_items( - journal_entry, - params.transaction_date, - params.posting_date, - params.t_accounts - ), - {:ok, final_je_update} <- - update_other_particulars( - initial_je_update, - params.journal_entry_number, - params.reference_number, - params.description, - params.particulars, - audit_log, - params.posted - ) do - {:ok, final_je_update} - else - _ -> {:error, :invalid_journal_entry} - end - end + # iex> JournalEntry.update(not_existing_journal_entry, %{}) + # {:error, :invalid_journal_entry} + # """ + # @spec update(__MODULE__.t(), map()) :: {:ok, __MODULE__.t()} | {:error, :invalid_journal_entry} + # def update(journal_entry, attrs) + # when is_map(attrs) and map_size(attrs) > 0 and journal_entry.posted == false do + # with {:ok, params} <- validate_update_params(journal_entry, attrs), + # {:ok, audit_log} <- + # AuditLog.create(%{ + # record_type: "journal_entry", + # action_type: "update", + # audit_details: params.audit_details + # }), + # {:ok, initial_je_update} <- + # update_dates_and_line_items( + # journal_entry, + # params.transaction_date, + # params.posting_date, + # params.t_accounts + # ), + # {:ok, final_je_update} <- + # update_other_particulars( + # initial_je_update, + # params.journal_entry_number, + # params.reference_number, + # params.description, + # params.particulars, + # audit_log, + # params.posted + # ) do + # {:ok, final_je_update} + # else + # _ -> {:error, :invalid_journal_entry} + # end + # end - def update(journal_entry, _) when journal_entry.posted == true, - do: {:error, :already_posted_journal_entry} + # def update(journal_entry, _) when journal_entry.posted == true, + # do: {:error, :already_posted_journal_entry} - def update(_, _), do: {:error, :invalid_journal_entry} + # def update(_, _), do: {:error, :invalid_journal_entry} - defp new( - transaction_date, - posting_date, - t_accounts, - journal_entry_number, - reference_number, - description, - particulars, - audit_details - ) do - with {:ok, line_items} <- LineItem.bulk_create(t_accounts), - {:ok, audit_log} <- - AuditLog.create(%{ - record_type: "journal_entry", - action_type: "create", - audit_details: audit_details - }) do - {:ok, - %__MODULE__{ - id: UUID.uuid4(), - transaction_date: transaction_date, - posting_date: posting_date, - line_items: line_items, - document_number: journal_entry_number, - reference_number: reference_number, - description: description, - particulars: particulars, - audit_logs: [audit_log] - }} - else - {:error, message} -> {:error, message} - end - end + # defp new( + # transaction_date, + # posting_date, + # t_accounts, + # journal_entry_number, + # reference_number, + # description, + # particulars, + # audit_details + # ) do + # with {:ok, line_items} <- LineItem.bulk_create(t_accounts), + # {:ok, audit_log} <- + # AuditLog.create(%{ + # record_type: "journal_entry", + # action_type: "create", + # audit_details: audit_details + # }) do + # {:ok, + # %__MODULE__{ + # transaction_date: transaction_date, + # posting_date: posting_date, + # line_items: line_items, + # document_number: journal_entry_number, + # reference_number: reference_number, + # description: description, + # particulars: particulars, + # audit_logs: [audit_log] + # }} + # else + # {:error, message} -> {:error, message} + # end + # end - defp validate_update_fields(params) do - is_binary(params.journal_entry_number) and params.journal_entry_number != "" and - is_binary(params.reference_number) and - is_binary(params.description) and - not is_nil(params.transaction_date) and not is_nil(params.posting_date) and - is_boolean(params.posted) and is_map(params.t_accounts) and is_map(params.audit_details) - end + # defp validate_update_fields(params) do + # is_binary(params.journal_entry_number) and params.journal_entry_number != "" and + # is_binary(params.reference_number) and + # is_binary(params.description) and + # not is_nil(params.transaction_date) and not is_nil(params.posting_date) and + # is_boolean(params.posted) and is_map(params.t_accounts) and is_map(params.audit_details) + # end - defp validate_update_params(journal_entry, attrs) do - params = %{ - transaction_date: Map.get(attrs, :transaction_date, journal_entry.transaction_date), - posting_date: Map.get(attrs, :posting_date, journal_entry.posting_date), - t_accounts: Map.get(attrs, :t_accounts, %{left: [], right: []}), - document_number: Map.get(attrs, :journal_entry_number, journal_entry.journal_entry_number), - reference_number: Map.get(attrs, :reference_number, journal_entry.reference_number), - description: Map.get(attrs, :description, journal_entry.description), - particulars: Map.get(attrs, :particulars, journal_entry.particulars), - posted: Map.get(attrs, :posted, journal_entry.posted), - audit_details: Map.get(attrs, :audit_details, %{}) - } + # defp validate_update_params(journal_entry, attrs) do + # params = %{ + # transaction_date: Map.get(attrs, :transaction_date, journal_entry.transaction_date), + # posting_date: Map.get(attrs, :posting_date, journal_entry.posting_date), + # t_accounts: Map.get(attrs, :t_accounts, %{left: [], right: []}), + # document_number: Map.get(attrs, :journal_entry_number, journal_entry.journal_entry_number), + # reference_number: Map.get(attrs, :reference_number, journal_entry.reference_number), + # description: Map.get(attrs, :description, journal_entry.description), + # particulars: Map.get(attrs, :particulars, journal_entry.particulars), + # posted: Map.get(attrs, :posted, journal_entry.posted), + # audit_details: Map.get(attrs, :audit_details, %{}) + # } - if validate_update_fields(params), - do: {:ok, params}, - else: {:error, :invalid_journal_entry} - end + # if validate_update_fields(params), + # do: {:ok, params}, + # else: {:error, :invalid_journal_entry} + # end - defp update_dates_and_line_items( - journal_entry, - transaction_date, - posting_date, - t_accounts - ) do - line_items = - if t_accounts == %{left: [], right: []} do - journal_entry.line_items - else - {:ok, line_items} = LineItem.bulk_create(t_accounts) - line_items - end + # defp update_dates_and_line_items( + # journal_entry, + # transaction_date, + # posting_date, + # t_accounts + # ) do + # line_items = + # if t_accounts == %{left: [], right: []} do + # journal_entry.line_items + # else + # {:ok, line_items} = LineItem.bulk_create(t_accounts) + # line_items + # end - update_params = %{ - transaction_date: transaction_date, - posting_date: posting_date, - line_items: line_items - } + # update_params = %{ + # transaction_date: transaction_date, + # posting_date: posting_date, + # line_items: line_items + # } - {:ok, Map.merge(journal_entry, update_params)} - end + # {:ok, Map.merge(journal_entry, update_params)} + # end - defp update_other_particulars( - journal_entry, - journal_entry_number, - reference_number, - description, - particulars, - audit_log, - posted - ) do - existing_audit_logs = Map.get(journal_entry, :audit_logs, []) + # defp update_other_particulars( + # journal_entry, + # journal_entry_number, + # reference_number, + # description, + # particulars, + # audit_log, + # posted + # ) do + # existing_audit_logs = Map.get(journal_entry, :audit_logs, []) - update_params = %{ - document_number: journal_entry_number, - reference_number: reference_number, - description: description, - particulars: particulars, - audit_logs: [audit_log | existing_audit_logs], - posted: posted - } + # update_params = %{ + # document_number: journal_entry_number, + # reference_number: reference_number, + # description: description, + # particulars: particulars, + # audit_logs: [audit_log | existing_audit_logs], + # posted: posted + # } - {:ok, Map.merge(journal_entry, update_params)} - end + # {:ok, Map.merge(journal_entry, update_params)} + # end end diff --git a/mix.exs b/mix.exs index 034a9a3..a0789d8 100644 --- a/mix.exs +++ b/mix.exs @@ -33,7 +33,6 @@ defmodule Bookkeeping.MixProject do # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:decimal, "~> 2.0"}, - {:uuid, "~> 1.1"}, {:excoveralls, "~> 0.10", only: :test}, {:nimble_csv, "~> 1.2"}, {:jason, "~> 1.4"}, diff --git a/mix.lock b/mix.lock index 456f860..794cb0f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,12 @@ %{ "benchee": {:hex, :benchee, "1.2.0", "afd2f0caec06ce3a70d9c91c514c0b58114636db9d83c2dc6bfd416656618353", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "ee729e53217898b8fd30aaad3cce61973dab61574ae6f48229fe7ff42d5e4457"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, - "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "excoveralls": {:hex, :excoveralls, "0.17.0", "279f124dba347903bb654bc40745c493ae265d45040001b4899ea1edf88078c7", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "08b638d114387a888f9cb8d65f2a0021ec04c3e447b793efa7c1e734aba93004"}, + "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, - "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, } diff --git a/test/bookkeeping/core/journal_entry_test.exs b/test/bookkeeping/core/journal_entry_test.exs index 9c8ffea..2c38172 100644 --- a/test/bookkeeping/core/journal_entry_test.exs +++ b/test/bookkeeping/core/journal_entry_test.exs @@ -2,280 +2,280 @@ defmodule Bookkeeping.Core.JournalEntryTest do use ExUnit.Case, async: true alias Bookkeeping.Core.{Account, JournalEntry} - setup do - transaction_date = DateTime.utc_now() - posting_date = DateTime.utc_now() - journal_entry_number = "JE100100" - reference_number = "INV100100" - audit_details = %{created_by: "example@example.com"} + # setup do + # transaction_date = DateTime.utc_now() + # posting_date = DateTime.utc_now() + # journal_entry_number = "JE100100" + # reference_number = "INV100100" + # audit_details = %{created_by: "example@example.com"} - {:ok, asset_account} = - Account.create("10000", "cash", "asset", "description", audit_details) + # {:ok, asset_account} = + # Account.create("10000", "cash", "asset", "description", audit_details) - {:ok, revenue_account} = - Account.create( - "20000", - "service revenue", - "revenue", - "description", - audit_details - ) + # {:ok, revenue_account} = + # Account.create( + # "20000", + # "service revenue", + # "revenue", + # "description", + # audit_details + # ) - t_accounts = %{ - left: [ - %{ - account: asset_account, - amount: Decimal.new(100), - description: "cash from service revenue" - } - ], - right: [ - %{ - account: revenue_account, - amount: Decimal.new(100), - description: "service revenue" - } - ] - } + # t_accounts = %{ + # left: [ + # %{ + # account: asset_account, + # amount: Decimal.new(100), + # description: "cash from service revenue" + # } + # ], + # right: [ + # %{ + # account: revenue_account, + # amount: Decimal.new(100), + # description: "service revenue" + # } + # ] + # } - particulars = %{approved_by: "example@example.com"} + # particulars = %{approved_by: "example@example.com"} - {:ok, - transaction_date: transaction_date, - posting_date: posting_date, - asset_account: asset_account, - revenue_account: revenue_account, - t_accounts: t_accounts, - document_number: journal_entry_number, - reference_number: reference_number, - particulars: particulars, - audit_details: audit_details} - end + # {:ok, + # transaction_date: transaction_date, + # posting_date: posting_date, + # asset_account: asset_account, + # revenue_account: revenue_account, + # t_accounts: t_accounts, + # document_number: journal_entry_number, + # reference_number: reference_number, + # particulars: particulars, + # audit_details: audit_details} + # end - test "create a journal entry", %{ - transaction_date: transaction_date, - posting_date: posting_date, - t_accounts: t_accounts, - document_number: journal_entry_number, - reference_number: reference_number, - particulars: particulars, - audit_details: audit_details - } do - assert {:ok, _journal_entry} = - JournalEntry.create( - transaction_date, - posting_date, - t_accounts, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) - end + # test "create a journal entry", %{ + # transaction_date: transaction_date, + # posting_date: posting_date, + # t_accounts: t_accounts, + # document_number: journal_entry_number, + # reference_number: reference_number, + # particulars: particulars, + # audit_details: audit_details + # } do + # assert {:ok, _journal_entry} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # t_accounts, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) + # end - test "disallow journal entry with invalid t_accounts", %{ - transaction_date: transaction_date, - posting_date: posting_date, - asset_account: asset_account, - revenue_account: revenue_account, - document_number: journal_entry_number, - reference_number: reference_number, - particulars: particulars, - audit_details: audit_details - } do - assert {:error, [:invalid_account]} = - JournalEntry.create( - transaction_date, - posting_date, - %{ - left: [%{account: "revenue_account", amount: Decimal.new(100)}], - right: [%{account: asset_account, amount: Decimal.new(100)}] - }, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) + # test "disallow journal entry with invalid t_accounts", %{ + # transaction_date: transaction_date, + # posting_date: posting_date, + # asset_account: asset_account, + # revenue_account: revenue_account, + # document_number: journal_entry_number, + # reference_number: reference_number, + # particulars: particulars, + # audit_details: audit_details + # } do + # assert {:error, [:invalid_account]} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # %{ + # left: [%{account: "revenue_account", amount: Decimal.new(100)}], + # right: [%{account: asset_account, amount: Decimal.new(100)}] + # }, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) - assert {:error, [:invalid_account]} = - JournalEntry.create( - transaction_date, - posting_date, - %{ - left: [%{account: revenue_account, amount: Decimal.new(100)}], - right: [%{account: "asset_account", amount: Decimal.new(100)}] - }, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) + # assert {:error, [:invalid_account]} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # %{ + # left: [%{account: revenue_account, amount: Decimal.new(100)}], + # right: [%{account: "asset_account", amount: Decimal.new(100)}] + # }, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) - assert {:error, :unbalanced_line_items} = - JournalEntry.create( - transaction_date, - posting_date, - %{ - left: [%{account: revenue_account, amount: Decimal.new(100)}], - right: [%{account: asset_account, amount: Decimal.new(200)}] - }, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) + # assert {:error, :unbalanced_line_items} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # %{ + # left: [%{account: revenue_account, amount: Decimal.new(100)}], + # right: [%{account: asset_account, amount: Decimal.new(200)}] + # }, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) - assert {:error, [:invalid_amount]} = - JournalEntry.create( - transaction_date, - posting_date, - %{ - left: [%{account: revenue_account, amount: 100}], - right: [%{account: asset_account, amount: Decimal.new(200)}] - }, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) + # assert {:error, [:invalid_amount]} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # %{ + # left: [%{account: revenue_account, amount: 100}], + # right: [%{account: asset_account, amount: Decimal.new(200)}] + # }, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) - assert {:error, [:invalid_amount]} = - JournalEntry.create( - transaction_date, - posting_date, - %{ - left: [%{account: revenue_account, amount: Decimal.new(200)}], - right: [%{account: asset_account, amount: 200}] - }, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) + # assert {:error, [:invalid_amount]} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # %{ + # left: [%{account: revenue_account, amount: Decimal.new(200)}], + # right: [%{account: asset_account, amount: 200}] + # }, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) - assert {:error, [:invalid_amount]} = - JournalEntry.create( - transaction_date, - posting_date, - %{ - left: [%{account: revenue_account, amount: 100}], - right: [%{account: asset_account, amount: Decimal.new(200)}] - }, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) - end + # assert {:error, [:invalid_amount]} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # %{ + # left: [%{account: revenue_account, amount: 100}], + # right: [%{account: asset_account, amount: Decimal.new(200)}] + # }, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) + # end - test "disallow journal entry with invalid fields", %{ - transaction_date: transaction_date, - posting_date: posting_date, - document_number: journal_entry_number, - reference_number: reference_number, - particulars: particulars, - audit_details: audit_details - } do - assert {:error, :invalid_journal_entry} = - JournalEntry.create( - nil, - nil, - %{}, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) + # test "disallow journal entry with invalid fields", %{ + # transaction_date: transaction_date, + # posting_date: posting_date, + # document_number: journal_entry_number, + # reference_number: reference_number, + # particulars: particulars, + # audit_details: audit_details + # } do + # assert {:error, :invalid_journal_entry} = + # JournalEntry.create( + # nil, + # nil, + # %{}, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) - assert {:error, :invalid_line_items} = - JournalEntry.create( - transaction_date, - posting_date, - %{}, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) - end + # assert {:error, :invalid_line_items} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # %{}, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) + # end - test "update journal entry", %{ - transaction_date: transaction_date, - posting_date: posting_date, - t_accounts: t_accounts, - asset_account: asset_account, - revenue_account: revenue_account, - document_number: journal_entry_number, - reference_number: reference_number, - particulars: particulars, - audit_details: audit_details - } do - assert {:ok, journal_entry} = - JournalEntry.create( - transaction_date, - posting_date, - t_accounts, - journal_entry_number, - reference_number, - "journal entry description", - particulars, - audit_details - ) + # test "update journal entry", %{ + # transaction_date: transaction_date, + # posting_date: posting_date, + # t_accounts: t_accounts, + # asset_account: asset_account, + # revenue_account: revenue_account, + # document_number: journal_entry_number, + # reference_number: reference_number, + # particulars: particulars, + # audit_details: audit_details + # } do + # assert {:ok, journal_entry} = + # JournalEntry.create( + # transaction_date, + # posting_date, + # t_accounts, + # journal_entry_number, + # reference_number, + # "journal entry description", + # particulars, + # audit_details + # ) - assert {:error, :invalid_journal_entry} = JournalEntry.update(journal_entry, %{}) + # assert {:error, :invalid_journal_entry} = JournalEntry.update(journal_entry, %{}) - assert {:ok, updated_journal_entry} = - JournalEntry.update(journal_entry, %{ - description: "second updated description", - particulars: %{approved_by: "other_example@example.com"}, - posted: false, - t_accounts: %{ - left: [%{account: asset_account, amount: Decimal.new(200)}], - right: [%{account: revenue_account, amount: Decimal.new(200)}] - } - }) + # assert {:ok, updated_journal_entry} = + # JournalEntry.update(journal_entry, %{ + # description: "second updated description", + # particulars: %{approved_by: "other_example@example.com"}, + # posted: false, + # t_accounts: %{ + # left: [%{account: asset_account, amount: Decimal.new(200)}], + # right: [%{account: revenue_account, amount: Decimal.new(200)}] + # } + # }) - assert updated_journal_entry.posting_date == - journal_entry.posting_date + # assert updated_journal_entry.posting_date == + # journal_entry.posting_date - assert updated_journal_entry.journal_entry_number == journal_entry.journal_entry_number + # assert updated_journal_entry.journal_entry_number == journal_entry.journal_entry_number - refute updated_journal_entry.description == - journal_entry.description + # refute updated_journal_entry.description == + # journal_entry.description - assert {:ok, updated_journal_entry} = - JournalEntry.update(journal_entry, %{ - description: "updated description", - posted: true - }) + # assert {:ok, updated_journal_entry} = + # JournalEntry.update(journal_entry, %{ + # description: "updated description", + # posted: true + # }) - assert updated_journal_entry.posting_date == - journal_entry.posting_date + # assert updated_journal_entry.posting_date == + # journal_entry.posting_date - assert updated_journal_entry.journal_entry_number == journal_entry.journal_entry_number + # assert updated_journal_entry.journal_entry_number == journal_entry.journal_entry_number - refute updated_journal_entry.description == - journal_entry.description + # refute updated_journal_entry.description == + # journal_entry.description - refute updated_journal_entry.posted == journal_entry.posted + # refute updated_journal_entry.posted == journal_entry.posted - assert {:error, :already_posted_journal_entry} = - JournalEntry.update(updated_journal_entry, %{ - description: "third description update", - posted: true, - t_accounts: %{ - left: [%{account: asset_account, amount: Decimal.new(200)}], - right: [%{account: revenue_account, amount: Decimal.new(200)}] - } - }) - end + # assert {:error, :already_posted_journal_entry} = + # JournalEntry.update(updated_journal_entry, %{ + # description: "third description update", + # posted: true, + # t_accounts: %{ + # left: [%{account: asset_account, amount: Decimal.new(200)}], + # right: [%{account: revenue_account, amount: Decimal.new(200)}] + # } + # }) + # end end From fa8272a2273eb4e57e9a738aaec355e3b89bb88e Mon Sep 17 00:00:00 2001 From: jeryldev Date: Fri, 22 Dec 2023 21:29:27 +0800 Subject: [PATCH 29/32] updated the account core and its test --- lib/bookkeeping/core/account.ex | 291 ++++++++++++------ .../bookkeeping/core/account_benchmark.exs | 63 +--- test/bookkeeping/core/account_test.exs | 163 +++++++--- 3 files changed, 332 insertions(+), 185 deletions(-) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 519b0ff..e1cb000 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -233,7 +233,14 @@ defmodule Bookkeeping.Core.Account do - audit_details: The details of the audit log. - active: The status of the account. - Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_params}` or `{:error, :invalid_field}`. + Returns `{:ok, %Account{...}}` if the account is valid. Otherwise, returns any of the following: + - `{:error, :invalid_params}` + - `{:error, :invalid_code}` + - `{:error, :invalid_name}` + - `{:error, :invalid_classification}` + - `{:error, :invalid_description}` + - `{:error, :invalid_audit_details}` + - `{:error, :invalid_active_state}` ## Examples @@ -243,12 +250,40 @@ defmodule Bookkeeping.Core.Account do iex> Account.create([]) {:error, :invalid_params} - iex> Account.create(%{code: "invalid", name: "invalid", classification: "invalid", description: nil, audit_details: false, active: %{}}) - {:error, :invalid_field} + iex> Account.create(%{code: nil, name: "cash", classification: "asset", description: "", audit_details: %{}, active: true}) + {:error, :invalid_code} + + iex> Account.create(%{code: "10_000", name: nil, classification: "asset", description: "", audit_details: %{}, active: true}) + {:error, :invalid_name} + + iex> Account.create(%{code: "10_000", name: "cash", classification: nil, description: "", audit_details: %{}, active: true}) + {:error, :invalid_classification} + + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", description: nil, audit_details: %{}, active: true}) + {:error, :invalid_description} + + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: nil, active: true}) + {:error, :invalid_audit_details} + + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: %{}, active: nil}) + {:error, :invalid_active_state} """ - @spec create(create_params()) :: {:ok, Account.t()} | {:error, :invalid_params | :invalid_field} + @spec create(create_params()) :: + {:ok, Account.t()} + | {:error, + :invalid_params + | :invalid_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_audit_details + | :invalid_active_state} def create(params) do - params |> validate_params() |> maybe_create() + params + |> validate_params() + |> validate_classification() + |> validate_audit_details() + |> maybe_create() end @doc """ @@ -262,7 +297,13 @@ defmodule Bookkeeping.Core.Account do - active: The status of the account. - audit_details: The details of the audit log. - Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`, `{:error, :invalid_field}`, or `{:error, :invalid_params}`. + Returns `{:ok, %Account{...}}` if the account is valid. Otherwise, returns any of the following: + - `{:error, :invalid_account}` + - `{:error, :invalid_name}` + - `{:error, :invalid_description}` + - `{:error, :invalid_audit_details}` + - `{:error, :invalid_active_state}` + - `{:error, :invalid_params}` ## Examples @@ -275,15 +316,34 @@ defmodule Bookkeeping.Core.Account do {:error, :invalid_account} iex> Account.update(account, %{name: nil}) - {:error, :invalid_field} + {:error, :invalid_name} + + iex> Account.update(account, %{description: nil}) + {:error, :invalid_description} + + iex> Account.update(account, %{audit_details: nil}) + {:error, :invalid_audit_details} + + iex> Account.update(account, %{active: nil}) + {:error, :invalid_active_state} iex> Account.update(account, nil) {:error, :invalid_params} """ @spec update(Account.t(), update_params()) :: - {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} + {:ok, Account.t()} + | {:error, + :invalid_account + | :invalid_name + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params} def update(account, params) do - params |> validate_update_params(account) |> maybe_update(account) + with {:ok, _account} <- validate(account), + {:ok, %{}} <- validate_update_params(params) do + {:ok, Map.merge(account, params)} + end end @doc """ @@ -292,7 +352,7 @@ defmodule Bookkeeping.Core.Account do Arguments: - account: The account to be validated. - Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`. + Returns `{:ok, %Account{...}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`. ## Examples @@ -302,44 +362,145 @@ defmodule Bookkeeping.Core.Account do iex> Account.validate(%Account{}) {:error, :invalid_account} """ - @spec validate(t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} - def validate(account) do - if is_struct(account, __MODULE__) and is_binary(account.code) and account.code != "" and - is_binary(account.name) and account.name != "" and is_binary(account.description) and - is_boolean(account.active) and is_list(account.audit_logs) and - is_struct(account.classification, Classification), - do: {:ok, account}, - else: {:error, :invalid_account} + @spec validate(Account.t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} + def validate(account) when is_struct(account, __MODULE__) do + validation_result = + account + |> validate_params() + |> validate_classification() + |> validate_audit_logs() + + case validation_result do + {:ok, account} -> {:ok, account} + {:error, _reason} -> {:error, :invalid_account} + end end - defp validate_params( - %{ - code: code, - name: name, - description: description, - classification: classification, - audit_details: audit_details, - active: active - } = params - ) do - if is_binary(code) and code != "" and is_binary(name) and name != "" and - is_binary(description) and is_binary(classification) and - classification in @account_classifications and - is_map(audit_details) and is_boolean(active), - do: params, - else: {:error, :invalid_field} + def validate(_), do: {:error, :invalid_account} + + defp validate_params(params) when is_map(params) and map_size(params) > 0 do + with {:ok, _} <- validate_code(params), + {:ok, _} <- validate_name(params), + {:ok, _} <- validate_description(params), + {:ok, _} <- validate_active_state(params) do + {:ok, params} + end end defp validate_params(_params), do: {:error, :invalid_params} - defp maybe_create(%{ - code: code, - name: name, - description: description, - classification: classification, - audit_details: audit_details, - active: active - }) do + defp validate_update_params(%{name: _name} = params) + when is_map(params) and map_size(params) > 0 do + with {:ok, _} <- validate_name(params) do + params + |> Map.delete(:name) + |> maybe_validate_update_params() + end + end + + defp validate_update_params(%{description: _description} = params) + when is_map(params) and map_size(params) > 0 do + with {:ok, _} <- validate_description(params) do + params + |> Map.delete(:description) + |> maybe_validate_update_params() + end + end + + defp validate_update_params(%{active: _active} = params) + when is_map(params) and map_size(params) > 0 do + with {:ok, _} <- validate_active_state(params) do + params + |> Map.delete(:active) + |> maybe_validate_update_params() + end + end + + defp validate_update_params(%{audit_details: _audit_details} = params) + when is_map(params) and map_size(params) > 0 do + with {:ok, _} <- validate_audit_details(params) do + params + |> Map.delete(:audit_details) + |> maybe_validate_update_params() + end + end + + defp validate_update_params(_params), do: {:error, :invalid_params} + + defp maybe_validate_update_params(params) when params == %{}, do: {:ok, %{}} + defp maybe_validate_update_params(params), do: validate_update_params(params) + + defp validate_code(%{code: code} = params) + when is_map(params) and is_binary(code) and code != "", + do: {:ok, params} + + defp validate_code(_params), do: {:error, :invalid_code} + + defp validate_name(%{name: name} = params) + when is_map(params) and is_binary(name) and name != "", + do: {:ok, params} + + defp validate_name(_params), do: {:error, :invalid_name} + + defp validate_description(%{description: description} = params) + when is_map(params) and is_binary(description), + do: {:ok, params} + + defp validate_description(_params), do: {:error, :invalid_description} + + defp validate_active_state(%{active: active} = params) + when is_map(params) and is_boolean(active), + do: {:ok, params} + + defp validate_active_state(_params), do: {:error, :invalid_active_state} + + defp validate_classification({:ok, account}) when is_struct(account, __MODULE__) do + valid_classification? = + account + |> Map.get(:classification) + |> is_struct(__MODULE__.Classification) + + if valid_classification?, + do: {:ok, account}, + else: {:error, :invalid_classification} + end + + defp validate_classification({:ok, %{classification: classification} = params}) + when is_binary(classification) and classification in @account_classifications, + do: {:ok, params} + + defp validate_classification({:error, reason}), do: {:error, reason} + defp validate_classification(_account_or_params), do: {:error, :invalid_classification} + + defp validate_audit_details({:ok, %{audit_details: audit_details} = params}) + when is_map(audit_details), + do: {:ok, params} + + defp validate_audit_details(%{audit_details: audit_details} = params) + when is_map(audit_details), + do: {:ok, params} + + defp validate_audit_details({:error, reason}), do: {:error, reason} + defp validate_audit_details(_params), do: {:error, :invalid_audit_details} + + defp validate_audit_logs({:ok, %{audit_logs: audit_logs} = account}) + when is_struct(account, __MODULE__) and is_list(audit_logs) and length(audit_logs) >= 1, + do: {:ok, account} + + defp validate_audit_logs({:error, reason}), do: {:error, reason} + defp validate_audit_logs(_account), do: {:error, :invalid_audit_logs} + + defp maybe_create( + {:ok, + %{ + code: code, + name: name, + description: description, + classification: classification, + audit_details: audit_details, + active: active + }} + ) do {:ok, audit_log} = AuditLog.create(%{ record_type: "account", @@ -361,50 +522,4 @@ defmodule Bookkeeping.Core.Account do end defp maybe_create({:error, reason}), do: {:error, reason} - - defp validate_update_params(params, _account) when not is_map(params) or params == %{}, - do: {:error, :invalid_params} - - defp validate_update_params(%{audit_details: _audit_details} = params, account) do - with {:ok, _} <- validate(account) do - Enum.reduce(params, %{}, fn - {_key, _value}, {:error, :invalid_field} -> {:error, :invalid_field} - {key, value}, acc -> verify_update_field(key, value, acc, account) - end) - end - end - - defp validate_update_params(params, account) do - params - |> Map.put(:audit_details, %{}) - |> validate_update_params(account) - end - - defp verify_update_field(key, value, acc, _account) - when key in [:name, :description] and - is_binary(value) and value != "", - do: Map.put(acc, key, value) - - defp verify_update_field(key, value, acc, _account) - when key == :active and is_boolean(value), - do: Map.put(acc, key, value) - - defp verify_update_field(key, value, acc, account) - when key == :audit_details and is_map(value) do - {:ok, audit_log} = - AuditLog.create(%{ - record_type: "account", - action_type: "update", - audit_details: value - }) - - Map.put(acc, :audit_logs, [audit_log | account.audit_logs]) - end - - defp verify_update_field(_key, _value, _acc, _account) do - {:error, :invalid_field} - end - - defp maybe_update({:error, reason}, _account), do: {:error, reason} - defp maybe_update(params, account), do: {:ok, Map.merge(account, params)} end diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs index 5aec36a..6af03df 100644 --- a/test/benchmark/bookkeeping/core/account_benchmark.exs +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -12,9 +12,6 @@ defmodule Bookkeeping.Core.AccountBenchmark do active: true }) end, - # "create/5" => fn -> - # Account.create("1001", "Cash 1", "asset", "Cash and Cash Equivalents 1", %{}) - # end, "create/1 struct only" => fn -> audit_log = AuditLog.create(%{ @@ -36,40 +33,19 @@ defmodule Bookkeeping.Core.AccountBenchmark do end }) - # Benchee.run(%{ - # "validate_account/1" => fn -> - # Account.validate_account(%Account{ - # code: "1003", - # name: "Cash 3", - # classification: "asset", - # description: "Cash and Cash Equivalents 3", - # audit_logs: [], - # active: true - # }) - # end, - # "validate/1" => fn -> - # Account.validate(%Account{ - # code: "1004", - # name: "Cash 4", - # classification: "asset", - # description: "Cash and Cash Equivalents 4", - # audit_logs: [], - # active: true - # }) - # end, - # "validate2/1" => fn -> - # Account.validate2(%Account{ - # code: "1005", - # name: "Cash 5", - # classification: "asset", - # description: "Cash and Cash Equivalents 5", - # audit_logs: [], - # active: true - # }) - # end - # }) + {:ok, cash_account_1} = + Account.create(%{ + code: "1000", + name: "Cash 0", + classification: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + }) + + Benchee.run(%{"validate/1" => fn -> Account.validate(cash_account_1) end}) - {:ok, cash_account} = + {:ok, cash_account_2} = Account.create(%{ code: "1000", name: "Cash 0", @@ -80,21 +56,10 @@ defmodule Bookkeeping.Core.AccountBenchmark do }) Benchee.run(%{ - "current update/2" => fn -> - random_string = for _ <- 1..10, into: "", do: <> - - Account.update(cash_account, %{ - code: random_string, - name: random_string, - description: "Cash and Cash Equivalents 1", - audit_details: %{email: "test@test.com"}, - active: false - }) - end, - "new update/2" => fn -> + "update/2" => fn -> random_string = for _ <- 1..10, into: "", do: <> - Account.update(cash_account, %{ + Account.update(cash_account_2, %{ code: random_string, name: random_string, classification: "asset", diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index d4cc378..5b00ee1 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -3,8 +3,16 @@ defmodule Bookkeeping.Core.AccountTest do alias Bookkeeping.Core.Account setup do - details = %{email: "example@example.com"} - {:ok, details: details} + params = %{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: %{email: "example@example.com"}, + active: true + } + + {:ok, params: params} end describe "Classification classify/1" do @@ -87,17 +95,8 @@ defmodule Bookkeeping.Core.AccountTest do end describe "create/1" do - test "with valid params", %{details: details} do - assert {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) - + test "with valid params", %{params: params} do + assert {:ok, account} = Account.create(params) assert account.code == "10_000" assert account.name == "cash" assert account.classification.name == "Asset" @@ -107,19 +106,47 @@ defmodule Bookkeeping.Core.AccountTest do assert is_list(account.audit_logs) assert is_struct(account.classification, Bookkeeping.Core.Account.Classification) end + + test "with invalid code", %{params: params} do + params = Map.put(params, :code, nil) + assert {:error, :invalid_code} = Account.create(params) + end + + test "with invalid name", %{params: params} do + params = Map.put(params, :name, nil) + assert {:error, :invalid_name} = Account.create(params) + end + + test "with invalid classification", %{params: params} do + params = Map.put(params, :classification, nil) + assert {:error, :invalid_classification} = Account.create(params) + end + + test "with invalid description", %{params: params} do + params = Map.put(params, :description, nil) + assert {:error, :invalid_description} = Account.create(params) + end + + test "with invalid active state", %{params: params} do + params = Map.put(params, :active, nil) + assert {:error, :invalid_active_state} = Account.create(params) + end + + test "with invalid audit_details", %{params: params} do + params = Map.put(params, :audit_details, nil) + assert {:error, :invalid_audit_details} = Account.create(params) + end + + test "with invalid params" do + assert {:error, :invalid_params} = Account.create(nil) + assert {:error, :invalid_params} = Account.create("apple") + assert {:error, :invalid_params} = Account.create(%{}) + end end describe "update/2" do - test "with valid params", %{details: details} do - assert {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: details, - active: true - }) + test "with valid params", %{params: params} do + assert {:ok, account} = Account.create(params) assert {:ok, account_2} = Account.update(account, %{ @@ -150,39 +177,79 @@ defmodule Bookkeeping.Core.AccountTest do assert {:error, :invalid_account} = Account.update(nil, params) end - test "with invalid field" do - {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: %{}, - active: true - }) + test "with invalid name", %{params: params} do + assert {:ok, account} = Account.create(params) + assert {:error, :invalid_name} = Account.update(account, %{name: nil}) + end - assert {:error, :invalid_field} = Account.update(account, %{name: nil}) - assert {:error, :invalid_field} = Account.update(account, %{name: "cash", active: nil}) - assert {:error, :invalid_field} = Account.update(account, %{name: "cash", test: "test"}) + test "with invalid description", %{params: params} do + assert {:ok, account} = Account.create(params) + assert {:error, :invalid_description} = Account.update(account, %{description: nil}) + end - assert {:error, :invalid_field} = - Account.update(account, %{name: "cash", classification: nil}) + test "with invalid active state", %{params: params} do + assert {:ok, account} = Account.create(params) + assert {:error, :invalid_active_state} = Account.update(account, %{active: nil}) end - test "with invalid params" do - {:ok, account} = - Account.create(%{ - code: "10_000", - name: "cash", - classification: "asset", - description: "description", - audit_details: %{}, - active: true - }) + test "with invalid audit_details", %{params: params} do + assert {:ok, account} = Account.create(params) + assert {:error, :invalid_audit_details} = Account.update(account, %{audit_details: nil}) + end + test "with invalid params", %{params: params} do + {:ok, account} = Account.create(params) assert {:error, :invalid_params} = Account.update(account, nil) assert {:error, :invalid_params} = Account.update(account, "apple") assert {:error, :invalid_params} = Account.update(account, %{}) end end + + describe "validate/1" do + test "with valid account", %{params: params} do + assert {:ok, account} = Account.create(params) + assert {:ok, _account} = Account.validate(account) + end + + test "with invalid account" do + assert {:error, :invalid_account} = Account.validate(%Account{}) + assert {:error, :invalid_account} = Account.validate(nil) + end + + test "with invalid code", %{params: params} do + assert {:ok, account} = Account.create(params) + account = Map.put(account, :code, nil) + assert {:error, :invalid_account} = Account.validate(account) + end + + test "with invalid name", %{params: params} do + assert {:ok, account} = Account.create(params) + account = Map.put(account, :name, nil) + assert {:error, :invalid_account} = Account.validate(account) + end + + test "with invalid classification", %{params: params} do + assert {:ok, account} = Account.create(params) + account = Map.put(account, :classification, nil) + assert {:error, :invalid_account} = Account.validate(account) + end + + test "with invalid description", %{params: params} do + assert {:ok, account} = Account.create(params) + account = Map.put(account, :description, nil) + assert {:error, :invalid_account} = Account.validate(account) + end + + test "with invalid active state", %{params: params} do + assert {:ok, account} = Account.create(params) + account = Map.put(account, :active, nil) + assert {:error, :invalid_account} = Account.validate(account) + end + + test "with invalid audit logs", %{params: params} do + assert {:ok, account} = Account.create(params) + account = Map.put(account, :audit_logs, nil) + assert {:error, :invalid_account} = Account.validate(account) + end + end end From d5c7dae5f88a3f78f8c82d25d65693c2dafc174d Mon Sep 17 00:00:00 2001 From: jeryldev Date: Fri, 22 Dec 2023 22:19:42 +0800 Subject: [PATCH 30/32] rework on line item and audit log --- lib/bookkeeping/core/audit_log.ex | 15 +- lib/bookkeeping/core/line_item.ex | 310 ++++++----------------- test/bookkeeping/core/line_item_test.exs | 138 ++-------- 3 files changed, 112 insertions(+), 351 deletions(-) diff --git a/lib/bookkeeping/core/audit_log.ex b/lib/bookkeeping/core/audit_log.ex index 272bb7b..2b2d8e1 100644 --- a/lib/bookkeeping/core/audit_log.ex +++ b/lib/bookkeeping/core/audit_log.ex @@ -75,17 +75,20 @@ defmodule Bookkeeping.Core.AuditLog do ) do if is_binary(record_type) and record_type != "" and is_binary(action_type) and action_type in @action_types and is_map(audit_details), - do: params, + do: {:ok, params}, else: {:error, :invalid_field} end defp validate_params(_), do: {:error, :invalid_params} - defp maybe_create(%{ - record_type: record_type, - action_type: action_type, - audit_details: audit_details - }) do + defp maybe_create( + {:ok, + %{ + record_type: record_type, + action_type: action_type, + audit_details: audit_details + }} + ) do unix_datetime = DateTime.to_unix(DateTime.utc_now()) created_at = if action_type == "create", do: unix_datetime, else: nil deleted_at = if action_type == "delete", do: unix_datetime, else: nil diff --git a/lib/bookkeeping/core/line_item.ex b/lib/bookkeeping/core/line_item.ex index f0804f8..abf3d43 100644 --- a/lib/bookkeeping/core/line_item.ex +++ b/lib/bookkeeping/core/line_item.ex @@ -3,7 +3,6 @@ defmodule Bookkeeping.Core.LineItem do Bookkeeping.Core.LineItem is a struct that represents a line item in a journal entry. A line item is a record of a single account and the amount of money that is either debited or credited. """ - alias Bookkeeping.Core.LineItem alias Bookkeeping.Core.{Account, Types} @typedoc """ @@ -12,14 +11,14 @@ defmodule Bookkeeping.Core.LineItem do @type t :: %__MODULE__{ account: Account.t(), amount: Decimal.t(), - entry_type: Types.entry(), - description: String.t() + entry: Types.entry(), + particulars: String.t() } defstruct account: nil, amount: 0, - entry_type: nil, - description: "" + entry: nil, + particulars: "" @doc """ Creates a new line item struct. @@ -28,32 +27,37 @@ defmodule Bookkeeping.Core.LineItem do - params: The params of the line item. It must contain the following keys: - account: The account of the line item. - amount: The amount of the line item. - - entry_type: The entry type of the line item. - - description: The description of the line item. + - entry: The entry type of the line item. + - particulars: The particulars of the line item. - Returns `{:ok, %LineItem{}}` if the line item is valid. Otherwise, returns `{:error, :invalid_account}`, `{:error, :invalid_amount}`, `{:error, :invalid_entry_type}`, `{:error, :invalid_description}`, or `{:error, :invalid_params}`. + Returns `{:ok, %LineItem{...}}` if the line item is valid. Otherwise, returns any of the following: + - `{:error, :invalid_account}` + - `{:error, :invalid_amount}` + - `{:error, :invalid_entry}` + - `{:error, :invalid_particulars}` + - `{:error, :invalid_params}`. ## Examples - iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: %{}, active: true}) + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", particulars: "", audit_details: %{}, active: true}) {:ok, asset_account} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :debit, description: ""}) + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :debit, particulars: ""}) {:ok, %LineItem{...}} - iex> LineItem.create(%{account: nil, amount: Decimal.new(100), entry_type: :debit, description: ""}) + iex> LineItem.create(%{account: nil, amount: Decimal.new(100), entry: :debit, particulars: ""}) {:error, :invalid_account} - iex> LineItem.create(%{account: asset_account, amount: 100, entry_type: :debit, description: ""}) + iex> LineItem.create(%{account: asset_account, amount: 100, entry: :debit, particulars: ""}) {:error, :invalid_amount} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :invalid, description: ""}) - {:error, :invalid_entry_type} + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :invalid, particulars: ""}) + {:error, :invalid_entry} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :debit, description: nil}) - {:error, :invalid_description} + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :debit, particulars: nil}) + {:error, :invalid_particulars} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry_type: :debit}) + iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :debit}) {:error, :invalid_params} """ @spec create(LineItem.t()) :: @@ -61,244 +65,98 @@ defmodule Bookkeeping.Core.LineItem do | {:error, :invalid_account | :invalid_amount - | :invalid_entry_type - | :invalid_description + | :invalid_entry + | :invalid_particulars | :invalid_params} def create(params) do params |> validate_params() |> maybe_create() end + @doc """ + Validates a line item struct. + + Arguments: + - line item: The line item to be validated + + Returns `{:ok, %LineItem{...}}` if the account is valid. Otherwise, returns `{:error, :invalid_line_item}`. + + ## Examples + + iex> LineItem.validate(line_item) + {:ok, %LineItem{...}} + + iex> LineItem.validate(%LineItem{}) + {:error, :invalid_line_item} + """ + @spec validate(__MODULE__.t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_line_item} def validate(line_item) - when is_struct(line_item, LineItem) do + when is_struct(line_item, __MODULE__) do case validate_params(line_item) do + {:ok, _line_item} -> {:ok, line_item} {:error, _reason} -> {:error, :invalid_line_item} - _params -> {:ok, line_item} end end def validate(_), do: {:error, :invalid_line_item} - defp validate_params( - %{ - account: account, - amount: amount, - entry_type: entry_type, - description: description - } = params - ) do - with {:ok, _account} <- Account.validate(account), - {:ok, _amount} <- validate_amount(amount), - {:ok, _entry_type} <- validate_entry_type(entry_type), - {:ok, _description} <- validate_description(description) do - params + defp validate_params(params) when is_map(params) and map_size(params) > 0 do + with {:ok, _} <- validate_account(params), + {:ok, _} <- validate_amount(params), + {:ok, _} <- validate_entry(params), + {:ok, _} <- validate_particulars(params) do + {:ok, params} end end defp validate_params(_), do: {:error, :invalid_params} - defp maybe_create(%{ - account: account, - amount: amount, - entry_type: entry_type, - description: description - }) do + defp maybe_create( + {:ok, + %{ + account: account, + amount: amount, + entry: entry, + particulars: particulars + }} + ) do {:ok, %__MODULE__{ account: account, amount: amount, - entry_type: entry_type, - description: description + entry: entry, + particulars: particulars }} end defp maybe_create({:error, reason}), do: {:error, reason} - defp validate_amount(amount) when is_struct(amount, Decimal) do + defp validate_account(%{account: account} = params) when is_map(params) do + case Account.validate(account) do + {:ok, _account} -> {:ok, params} + {:error, _reason} -> {:error, :invalid_account} + end + end + + defp validate_account(_params), do: {:error, :invalid_account} + + defp validate_amount(%{amount: amount} = params) + when is_map(params) and is_struct(amount, Decimal) do if Decimal.gt?(amount, Decimal.new(0)), - do: {:ok, amount}, + do: {:ok, params}, else: {:error, :invalid_amount} end - defp validate_amount(_), do: {:error, :invalid_amount} - - defp validate_entry_type(type) when type in [:debit, :credit], do: {:ok, type} - defp validate_entry_type(_), do: {:error, :invalid_entry_type} - - defp validate_description(description) when is_binary(description), do: {:ok, description} - defp validate_description(_), do: {:error, :invalid_description} - # @typedoc """ - # t_accounts type is a map that represents the debit and credit lists of line amount data. - # """ - # @type t_accounts :: %{ - # left: list(line_amount_data()), - # right: list(line_amount_data()) - # } - - # @typedoc """ - # line_amount_data type is a map that represents the account, amount, and description of a line item. - # """ - # @type line_amount_data :: %{ - # account: Account.t(), - # amount: Decimal.t(), - # description: String.t() - # } - - # @doc """ - # Creates a list of line item structs. - - # Arguments: - # - t_accounts: The map of line items. The map must have the following keys: - # - left: The list of maps with account, amount, and description field and represents the entry type of debit. - # - right: The list of maps with account, amount, and description field and represents the entry type of credit. - - # Returns `{:ok, [%LineItem{}, ...]}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`. - - # ## Examples - - # iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100), description: ""}], right: [%{account: asset_account, amount: Decimal.new(100), description: ""}]}) - # {:ok, [%LineItem{...}, %LineItem{...}]} - - # iex> LineItem.bulk_create(%{left: [%{account: expense_account, amount: Decimal.new(100)}], right: []}) - # {:error, :unbalanced_line_items} - # """ - # @spec bulk_create(t_accounts()) :: - # {:ok, list(__MODULE__.t())} - # | {:error, %{message: :invalid_line_items, errors: list(atom())}} - # | {:error, :invalid_line_items} - # def bulk_create(%{left: left, right: right} = t_accounts) when left != [] and right != [] do - # bulk_create_result = - # t_accounts - # |> Task.async_stream(fn - # {:left, debit_items} -> Task.async_stream(debit_items, &create(&1, :debit)) - # {:right, credit_items} -> Task.async_stream(credit_items, &create(&1, :credit)) - # end) - # |> Enum.reduce( - # %{ - # debit_balance: Decimal.new(0), - # credit_balance: Decimal.new(0), - # balanced: false, - # created_line_items: [], - # errors: [] - # }, - # &validate_line_items/2 - # ) - - # case bulk_create_result.created_line_items do - # [] -> - # {:error, %{message: :invalid_line_items, errors: bulk_create_result.errors}} - - # created_line_items -> - # cond do - # bulk_create_result.errors != [] -> {:error, bulk_create_result.errors} - # bulk_create_result.balanced == false -> {:error, :unbalanced_line_items} - # true -> {:ok, created_line_items} - # end - # end - # end - - # def bulk_create(_), do: {:error, :invalid_line_items} - - # @doc """ - # Creates a new line item struct. - - # Arguments: - # - account_amount_pair: The map with account and amount field. - # - atom_entry_type: The atom that represents the entry type of the line item. The atom must be either `:debit` or `:credit`. - # - description (optional): The description of the line item. - - # Returns `{:ok, %LineItem{}}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`, `{:error, :unbalanced_line_items}`, or `{:error, list(:invalid_amount | :invalid_account | :inactive_account)}`. - - # ## Examples - - # iex> LineItem.create(line_amount_data(), :debit) - # {:ok, %LineItem{...}} - # """ - # @spec create(line_amount_data(), Types.entry()) :: - # {:ok, __MODULE__.t()} - # | {:error, :invalid_line_items} - # | {:error, :unbalanced_line_items} - # | {:error, list(:invalid_amount | :invalid_account | :inactive_account)} - # def create(account_amount_pair, atom_entry_type) do - # with {:ok, %{account: account, amount: amount}} <- - # validate_account_and_amount(account_amount_pair), - # {:ok, entry_type} <- validate_entry_type(atom_entry_type) do - # description = Map.get(account_amount_pair, :description, "") - - # {:ok, - # %__MODULE__{ - # account: account, - # amount: amount, - # entry_type: entry_type, - # description: description - # }} - # else - # {:error, message} -> {:error, message} - # _ -> {:error, :invalid_line_items} - # end - # end - - # defp validate_line_items({:ok, line_items}, acc) do - # Enum.reduce(line_items, acc, fn - # {:ok, {:ok, line_item}}, acc -> process_line_item(acc, line_item) - # {:ok, {:error, message}}, acc -> Map.put(acc, :errors, [message | acc.errors]) - # end) - # end - - # defp validate_account_and_amount(account_amount_pair) when is_map(account_amount_pair) do - # account = Map.get(account_amount_pair, :account) - # amount = Map.get(account_amount_pair, :amount) - - # with {:ok, account} <- validate_account(account), - # {:ok, amount} <- validate_amount(amount) do - # {:ok, %{account: account, amount: amount}} - # else - # {:error, message} -> {:error, message} - # _ -> {:error, :invalid_line_items} - # end - # end - - # defp validate_account_and_amount(_), do: {:error, :invalid_account_and_amount_map} - - # defp validate_account(account) - # when is_struct(account, Account) and not account.active, - # do: {:error, :inactive_account} - - # defp validate_account(account) - # when is_struct(account, Account) and account.active, - # do: {:ok, account} - - # defp validate_account(_), do: {:error, :invalid_account} - - # defp validate_amount(amount) when is_struct(amount, Decimal) do - # if Decimal.gt?(amount, Decimal.new(0)), - # do: {:ok, amount}, - # else: {:error, :invalid_amount} - # end - - # defp validate_amount(_), do: {:error, :invalid_amount} - - # defp validate_entry_type(:debit), do: {:ok, :debit} - # defp validate_entry_type(:credit), do: {:ok, :credit} - - # defp process_line_item(acc, line_item) do - # entry_type = line_item.entry_type - - # updated_debit_balance = - # if entry_type == :debit, - # do: Decimal.add(acc.debit_balance, line_item.amount), - # else: acc.debit_balance - - # updated_credit_balance = - # if entry_type == :credit, - # do: Decimal.add(acc.credit_balance, line_item.amount), - # else: acc.credit_balance - - # %{ - # debit_balance: updated_debit_balance, - # credit_balance: updated_credit_balance, - # balanced: Decimal.equal?(updated_debit_balance, updated_credit_balance), - # created_line_items: [line_item | acc.created_line_items], - # errors: acc.errors - # } - # end + defp validate_amount(_params), do: {:error, :invalid_amount} + + defp validate_entry(%{entry: entry} = params) + when is_map(params) and entry in [:debit, :credit], + do: {:ok, params} + + defp validate_entry(_params), do: {:error, :invalid_entry} + + defp validate_particulars(%{particulars: particulars} = params) + when is_map(params) and is_binary(particulars), + do: {:ok, params} + + defp validate_particulars(_params), do: {:error, :invalid_particulars} end diff --git a/test/bookkeeping/core/line_item_test.exs b/test/bookkeeping/core/line_item_test.exs index afc38c1..a9be133 100644 --- a/test/bookkeeping/core/line_item_test.exs +++ b/test/bookkeeping/core/line_item_test.exs @@ -17,8 +17,8 @@ defmodule Bookkeeping.Core.LineItemTest do params = %{ account: account, amount: Decimal.new(100), - entry_type: :debit, - description: "line description" + entry: :debit, + particulars: "line particulars" } {:ok, account: account, params: params} @@ -30,28 +30,36 @@ defmodule Bookkeeping.Core.LineItemTest do assert line_item.account == params.account assert line_item.amount == params.amount - assert line_item.entry_type == params.entry_type - assert line_item.description == params.description + assert line_item.entry == params.entry + assert line_item.particulars == params.particulars end test "with invalid account", %{params: params} do params = Map.put(params, :account, %{}) assert {:error, :invalid_account} = LineItem.create(params) + params = Map.delete(params, :account) + assert {:error, :invalid_account} = LineItem.create(params) end test "with invalid amount", %{params: params} do params = Map.put(params, :amount, 100) assert {:error, :invalid_amount} = LineItem.create(params) + params = Map.delete(params, :amount) + assert {:error, :invalid_amount} = LineItem.create(params) end test "with invalid entry type", %{params: params} do - params = Map.put(params, :entry_type, "invalid") - assert {:error, :invalid_entry_type} = LineItem.create(params) + params = Map.put(params, :entry, "invalid") + assert {:error, :invalid_entry} = LineItem.create(params) + params = Map.delete(params, :entry) + assert {:error, :invalid_entry} = LineItem.create(params) end - test "with invalid description", %{params: params} do - params = Map.put(params, :description, nil) - assert {:error, :invalid_description} = LineItem.create(params) + test "with invalid particulars", %{params: params} do + params = Map.put(params, :particulars, nil) + assert {:error, :invalid_particulars} = LineItem.create(params) + params = Map.delete(params, :particulars) + assert {:error, :invalid_particulars} = LineItem.create(params) end test "with invalid params" do @@ -64,7 +72,7 @@ defmodule Bookkeeping.Core.LineItemTest do describe "validate/1" do test "with valid line_item", %{params: params} do assert {:ok, line_item} = LineItem.create(params) - assert {:ok, line_item} = LineItem.validate(line_item) + assert {:ok, _line_item} = LineItem.validate(line_item) end test "with invalid line_item", %{params: params} do @@ -73,116 +81,8 @@ defmodule Bookkeeping.Core.LineItemTest do assert {:error, :invalid_line_item} = LineItem.validate("") assert {:error, :invalid_line_item} = LineItem.validate(params) assert {:ok, line_item} = LineItem.create(params) - modified_line_item = Map.put(line_item, :description, nil) + modified_line_item = Map.put(line_item, :particulars, nil) assert {:error, :invalid_line_item} = LineItem.validate(modified_line_item) end end - - defp update_params(params) do - code = random_string() - name = random_string() - Map.merge(params, %{code: code, name: name}) - end - - defp random_string do - for _ <- 1..10, into: "", do: <> - end - - # test "bulk create line items", %{details: details} do - # assert {:ok, asset_account} = - # Account.create("10000", "cash", "asset", "description", details) - - # assert {:ok, expense_account} = - # Account.create("20000", "rent", "expense", "description", details) - - # assert {:ok, bulk_create_result} = - # LineItem.bulk_create(%{ - # left: [ - # %{ - # account: expense_account, - # amount: Decimal.new(100), - # description: "rent expense" - # } - # ], - # right: [ - # %{ - # account: asset_account, - # amount: Decimal.new(100), - # description: "cash paid for rent" - # } - # ] - # }) - - # refute bulk_create_result == [] - - # assert {:error, %{message: :invalid_line_items, errors: [:invalid_account, :invalid_account]}} = - # LineItem.bulk_create(%{ - # left: [%{account: "expense_account", amount: Decimal.new(100)}], - # right: [%{account: "asset_account", amount: Decimal.new(100)}] - # }) - - # assert {:error, :invalid_line_items} = LineItem.bulk_create(%{}) - - # assert {:error, [:invalid_account]} = - # LineItem.bulk_create(%{ - # left: [%{account: expense_account, amount: Decimal.new(100)}], - # right: [%{account: "asset_account", amount: Decimal.new(100)}] - # }) - - # assert {:error, :invalid_line_items} = - # LineItem.bulk_create(%{ - # left: [%{account: expense_account, amount: Decimal.new(100)}], - # right: [] - # }) - - # assert {:error, :unbalanced_line_items} = - # LineItem.bulk_create(%{ - # left: [%{account: expense_account, amount: Decimal.new(100)}], - # right: [%{account: asset_account, amount: Decimal.new(200)}] - # }) - - # assert {:error, [:invalid_account]} = - # LineItem.bulk_create(%{ - # left: [%{account: expense_account, amount: Decimal.new(100)}], - # right: [%{account: asset_account, amount: Decimal.new(100)}, %{}] - # }) - - # assert {:ok, expense_account_2} = - # Account.create("20020", "depreciation", "expense", "description", details) - - # assert {:ok, updated_expense_account_2} = - # Account.update(expense_account_2, %{name: "depreciation expense", active: false}) - - # assert {:error, [:inactive_account]} = - # LineItem.bulk_create(%{ - # left: [%{account: updated_expense_account_2, amount: Decimal.new(100)}], - # right: [%{account: asset_account, amount: Decimal.new(100)}] - # }) - # end - - # test "create line item with valid account, amount, and binary_entry_type", %{details: details} do - # assert {:ok, asset_account} = Account.create("10000", "cash", "asset", "description", details) - - # assert {:ok, line_item} = - # LineItem.create(%{account: asset_account, amount: Decimal.new(100)}, :debit) - - # assert line_item.account == asset_account - # assert line_item.amount == Decimal.new(100) - # assert line_item.entry_type == :debit - # assert line_item.description == "" - # end - - # test "disallow line item with invalid fields" do - # assert {:error, :invalid_account} = - # LineItem.create(%{account: "asset", amount: Decimal.new(100)}, "invalid") - - # assert {:error, :invalid_account_and_amount_map} = LineItem.create(nil, "invalid") - # end - - # test "disallow line item with invalid amount", %{details: details} do - # {:ok, asset_account} = Account.create("10000", "cash", "asset", "description", details) - - # assert {:error, :invalid_amount} = - # LineItem.create(%{account: asset_account, amount: 100}, :debit) - # end end From 3eca4eeb22000a142a82bce45a4d58a3d1a2b39f Mon Sep 17 00:00:00 2001 From: jeryldev Date: Sun, 24 Dec 2023 18:53:02 +0800 Subject: [PATCH 31/32] fix the issues on the chart of accounts --- lib/bookkeeping.ex | 94 ++++++-- .../boundary/chart_of_accounts/worker.ex | 189 +++++++++++++++- lib/bookkeeping/core/account.ex | 214 +++++++----------- lib/bookkeeping/core/audit_log.ex | 113 ++++----- lib/bookkeeping/core/line_item.ex | 68 +++--- lib/bookkeeping/core/types.ex | 7 + .../bookkeeping/core/account_benchmark.exs | 6 +- .../boundary/chart_of_accounts_test.exs | 57 +++-- test/bookkeeping/core/account_test.exs | 5 + test/bookkeeping/core/audit_log_test.exs | 33 +-- test/bookkeeping/core/line_item_test.exs | 8 +- test/bookkeeping_test.exs | 94 +++++--- 12 files changed, 579 insertions(+), 309 deletions(-) diff --git a/lib/bookkeeping.ex b/lib/bookkeeping.ex index 27a137b..bec31f7 100644 --- a/lib/bookkeeping.ex +++ b/lib/bookkeeping.ex @@ -29,20 +29,56 @@ defmodule Bookkeeping do - audit_details: The details of the audit log. - active: The status of the account. The account status must be one of the following: `true` or `false`. - Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_params}` or `{:error, :invalid_field}`. + Returns `{:ok, Account.t()}` if the account is valid. Otherwise, returns any of the following: + - `{:error, :already_exists}` + - `{:error, :invalid_code}` + - `{:error, :invalid_name}` + - `{:error, :invalid_classification}` + - `{:error, :invalid_description}` + - `{:error, :invalid_active_state}` + - `{:error, :invalid_audit_details}` + - `{:error, :invalid_params}` ## Examples - iex> Bookkeeping.create_account(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: %{}, active: true}) - {:ok, %Account{...}} + iex> Bookkeeping.create_account(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:ok, %Bookkeeping.Core.Account{...}} - iex> Bookkeeping.create_account([])" - {:error, :invalid_params} + iex> Bookkeeping.create_account(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :already_exists} + + iex> Bookkeeping.create_account(%{code: nil, name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :invalid_code} + + iex> Bookkeeping.create_account(%{code: "1000", name: nil, classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :invalid_name} + + iex> Bookkeeping.create_account(%{code: "1000", name: "Cash 0", classification: "invalid", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :invalid_classification} + + iex> Bookkeeping.create_account(%{code: "1000", name: "Cash 0", classification: "asset", description: nil, audit_details: %{}, active: true}) + {:error, :invalid_description} - iex> Bookkeeping.create_account(%{code: "invalid", name: "invalid", classification: "invalid", description: nil, audit_details: false, active: %{}}) - {:error, :invalid_field} + iex> Bookkeeping.create_account(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: nil, active: true}) + {:error, :invalid_audit_details} + + iex> Bookkeeping.create_account(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: nil}) + {:error, :invalid_active_state} + + iex> Bookkeeping.create_account(nil) + {:error, :invalid_params} """ - @spec create_account(map()) :: {:ok, Account.t()} | {:error, :invalid_params | :invalid_field} + @spec create_account(map()) :: + {:ok, Account.t()} + | {:error, + :already_exists + | :invalid_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params} def create_account(params), do: ChartOfAccounts.create(params) @doc """ @@ -61,7 +97,7 @@ defmodule Bookkeeping do {:ok, %{ accounts: [%Bookkeeping.Core.Account{...}, %Bookkeeping.Core.Account{...}, ...], - errors: [] + errors: [%{reason: :already_exists, params: %{...}}, %{reason: :invalid_code, params: %{...}}, ...] }} iex> Bookkeeping.import_accounts("../../data/invalid_file.csv") @@ -73,7 +109,15 @@ defmodule Bookkeeping do accounts: list(Account.t()), errors: list(%{ - reason: :invalid_params | :invalid_field | :already_exists, + reason: + :already_exists + | :invalid_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params, params: Account.create_params() }) }} @@ -95,20 +139,36 @@ defmodule Bookkeeping do ## Examples - iex> Bookkeeping.update_account(account, %{name: "Cash and cash equivalents"}) + iex> Bookkeeping.find_account_by_code("1000") + {:ok, %Bookkeeping.Core.Account{...}} + + iex> Bookkeeping.update_account(%Bookkeeping.Core.Account{...}, %{name: "Cash 1", description: "Cash and Cash Equivalents 1", audit_details: %{}, active: true}) {:ok, %Bookkeeping.Core.Account{...}} - iex> Bookkeeping.update_account(account, %{name: "Cash and cash equivalents"}) - {:error, :invalid_account} + iex> Bookkeeping.update_account(%Bookkeeping.Core.Account{...}, %{name: nil, description: "Cash and Cash Equivalents 1", audit_details: %{}, active: true}) + {:error, :invalid_name} + + iex> Bookkeeping.update_account(%Bookkeeping.Core.Account{...}, %{name: "Cash 1", description: nil, audit_details: %{}, active: true}) + {:error, :invalid_description} + + iex> Bookkeeping.update_account(%Bookkeeping.Core.Account{...}, %{name: "Cash 1", description: "Cash and Cash Equivalents 1", audit_details: nil, active: true}) + {:error, :invalid_audit_details} - iex> Bookkeeping.update_account(account, %{code: "1002"}) - {:error, :invalid_field} + iex> Bookkeeping.update_account(%Bookkeeping.Core.Account{...}, %{name: "Cash 1", description: "Cash and Cash Equivalents 1", audit_details: %{}, active: nil}) + {:error, :invalid_active_state} - iex> Bookkeeping.update_account(account, nil) + iex> Bookkeeping.update_account(%Bookkeeping.Core.Account{...}, nil) {:error, :invalid_params} """ @spec update_account(Account.t(), map()) :: - {:ok, Account.t()} | {:error, :invalid_account | :invalid_field | :invalid_params} + {:ok, Account.t()} + | {:error, + :invalid_account + | :invalid_name + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params} def update_account(account, params), do: ChartOfAccounts.update(account, params) @doc """ diff --git a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex index 865676b..22d2c1e 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/worker.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -10,6 +10,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do alias Bookkeeping.Core.Account alias NimbleCSV.RFC4180, as: CSV + @doc """ + Starts the Chart of Accounts worker. + It is started automatically by the Bookkeeping application. + """ @spec start_link(any()) :: {:ok, pid()} | {:error, any()} | {:error, :already_started} def start_link(_) do case GenServer.start_link(__MODULE__, :ok, name: __MODULE__) do @@ -18,18 +22,104 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do end end + @doc """ + Creates a new account and inserts it into the Chart of Accounts ETS table. + + Arguments: + - params: The parameters of the account. It must contain the following keys: + - code: The code of the account. + - name: The name of the account. + - description: The description of the account. + - classification: The classification of the account. + - audit_details: The details of the audit log. + - active: The status of the account. + + Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns any of the following: + - `{:error, :already_exists}` + - `{:error, :invalid_code}` + - `{:error, :invalid_name}` + - `{:error, :invalid_description}` + - `{:error, :invalid_classification}` + - `{:error, :invalid_audit_details}` + - `{:error, :invalid_active_state}` + - `{:error, :invalid_params}` + + ## Examples + + iex> Account.create(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:ok, %Account{...}} + + iex> Account.create(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :already_exists} + + iex> Account.create(%{code: nil, name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :invalid_code} + + iex> Account.create(%{code: "1000", name: nil, classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :invalid_name} + + iex> Account.create(%{code: "1000", name: "Cash 0", classification: nil, description: "Cash and Cash Equivalents 0", audit_details: %{}, active: true}) + {:error, :invalid_classification} + + iex> Account.create(%{code: "1000", name: "Cash 0", classification: "asset", description: nil, audit_details: %{}, active: true}) + {:error, :invalid_description} + + iex> Account.create(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: nil, active: true}) + {:error, :invalid_audit_details} + + iex> Account.create(%{code: "1000", name: "Cash 0", classification: "asset", description: "Cash and Cash Equivalents 0", audit_details: %{}, active: nil}) + {:error, :invalid_active_state} + + iex> Account.create(nil) + {:error, :invalid_params} + """ @spec create(Account.create_params()) :: {:ok, Account.t()} - | {:error, :already_exists | :invalid_field | :invalid_params} + | {:error, + :already_exists + | :invalid_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params} def create(params), do: maybe_handle_call({:create, params}) + @doc """ + Imports a CSV file containing the accounts to be created and inserts them into the Chart of Accounts ETS table. + + Arguments: + - file_path: The path of the CSV file. + + Returns `{:ok, %{accounts: list(%Account{}), errors: list(%{reason: atom(), params: Account.create_params()})}}` if the CSV file is valid. Otherwise, returns `{:error, :invalid_file}`. + + ## Examples + + iex> Account.import_file("test/support/accounts.csv") + {:ok, %{accounts: [%Account{...}], errors: []}} + + iex> Account.import_file("test/support/invalid_accounts.csv") + {:ok, %{accounts: [], errors: [%{reason: :invalid_code, params: %{...}}]}} + + iex> Account.import_file("test/support/invalid_file.csv") + {:error, :invalid_file} + """ @spec import_file(String.t()) :: {:ok, %{ accounts: list(Account.t()), errors: list(%{ - reason: :invalid_params | :invalid_field | :already_exists, + reason: + :already_exists + | :invalid_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params, params: Account.create_params() }) }} @@ -42,17 +132,110 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Worker do |> bulk_create() end + @doc """ + Updates an existing account in the Chart of Accounts ETS table. + + Arguments: + - account: The account to be updated. + - params: The parameters of the account. It must contain the following keys: + - name: The name of the account. + - description: The description of the account. + - active: The status of the account. + - audit_details: The details of the audit log. + + Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns any of the following: + - `{:error, :invalid_account}` + - `{:error, :invalid_name}` + - `{:error, :invalid_description}` + - `{:error, :invalid_active_state}` + - `{:error, :invalid_audit_details}` + - `{:error, :invalid_params}` + + ## Examples + + iex> Account.update(%Account{...}, %{name: "Cash 1", description: "Cash and Cash Equivalents 1", audit_details: %{}, active: true}) + {:ok, %Account{...}} + + iex> Account.update(%Account{...}, %{name: nil, description: "Cash and Cash Equivalents 1", audit_details: %{}, active: true}) + {:error, :invalid_name} + + iex> Account.update(%Account{...}, %{name: "Cash 1", description: nil, audit_details: %{}, active: true}) + {:error, :invalid_description} + + iex> Account.update(%Account{...}, %{name: "Cash 1", description: "Cash and Cash Equivalents 1", audit_details: %{}, active: nil}) + {:error, :invalid_active_state} + + iex> Account.update(%Account{...}, %{name: "Cash 1", description: "Cash and Cash Equivalents 1", audit_details: nil, active: true}) + {:error, :invalid_audit_details} + + iex> Account.update(%Account{...}, nil) + {:error, :invalid_params} + """ @spec update(Account.t(), Account.update_params()) :: {:ok, Account.t()} - | {:error, :invalid_account | :invalid_field | :invalid_params} + | {:error, + :invalid_account + | :invalid_name + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params} def update(account, params), do: maybe_handle_call({:update, account, params}) + @doc """ + Searches for an account in the Chart of Accounts ETS table by its code. + + Arguments: + - code: The code of the account. + + Returns `{:ok, list(%Account{})}` if the code is valid. Otherwise, returns `{:error, :invalid_code}`. + + ## Examples + + iex> Account.search_code("1000") + {:ok, [%Account{...}]} + + iex> Account.search_code(nil) + {:error, :invalid_code} + """ @spec all_accounts() :: {:ok, list(Account.t())} def all_accounts, do: maybe_handle_call(:all_accounts) + @doc """ + Searches for an account in the Chart of Accounts ETS table by its code. + + Arguments: + - code: The code of the account. + + Returns `{:ok, list(%Account{})}` if the code is valid. Otherwise, returns `{:error, :invalid_code}`. + + ## Examples + + iex> Account.search_code("1000") + {:ok, [%Account{...}]} + + iex> Account.search_code(nil) + {:error, :invalid_code} + """ @spec search_code(Account.account_code()) :: {:ok, list(Account.t())} | {:error, :invalid_code} def search_code(code), do: maybe_handle_call({:search_code, code}) + @doc """ + Searches for an account in the Chart of Accounts ETS table by its name. + + Arguments: + - name: The name of the account. + + Returns `{:ok, list(%Account{})}` if the name is valid. Otherwise, returns `{:error, :invalid_name}`. + + ## Examples + + iex> Account.search_name("Cash 0") + {:ok, [%Account{...}]} + + iex> Account.search_name(nil) + {:error, :invalid_name} + """ @spec search_name(String.t()) :: {:ok, list(Account.t())} | {:error, :invalid_name} def search_name(name), do: maybe_handle_call({:search_name, name}) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index e1cb000..1478cf5 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -276,14 +276,21 @@ defmodule Bookkeeping.Core.Account do | :invalid_name | :invalid_classification | :invalid_description - | :invalid_audit_details - | :invalid_active_state} + | :invalid_active_state + | :invalid_audit_details} def create(params) do - params - |> validate_params() - |> validate_classification() - |> validate_audit_details() - |> maybe_create() + with {:ok, params} <- validate_params(params), + {:ok, audit_log} <- validate_audit_details(params, "create") do + {:ok, + %__MODULE__{ + code: params.code, + name: params.name, + description: params.description, + classification: Classification.classify(params.classification), + audit_logs: [audit_log], + active: params.active + }} + end end @doc """ @@ -341,7 +348,9 @@ defmodule Bookkeeping.Core.Account do | :invalid_params} def update(account, params) do with {:ok, _account} <- validate(account), - {:ok, %{}} <- validate_update_params(params) do + {:ok, %{}} <- validate_update_params(params), + {:ok, audit_log} <- validate_audit_details(params, "update") do + params = Map.put(params, :audit_logs, [audit_log | account.audit_logs]) {:ok, Map.merge(account, params)} end end @@ -364,162 +373,107 @@ defmodule Bookkeeping.Core.Account do """ @spec validate(Account.t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} def validate(account) when is_struct(account, __MODULE__) do - validation_result = - account - |> validate_params() - |> validate_classification() - |> validate_audit_logs() - - case validation_result do - {:ok, account} -> {:ok, account} - {:error, _reason} -> {:error, :invalid_account} + with {:ok, account} <- validate_params(account), + {:ok, _audit_logs} <- validate_audit_logs(account) do + {:ok, account} + else + _ -> {:error, :invalid_account} end end def validate(_), do: {:error, :invalid_account} - defp validate_params(params) when is_map(params) and map_size(params) > 0 do - with {:ok, _} <- validate_code(params), - {:ok, _} <- validate_name(params), - {:ok, _} <- validate_description(params), - {:ok, _} <- validate_active_state(params) do - {:ok, params} + defp validate_params( + %{ + code: code, + name: name, + description: description, + active: active, + classification: classification + } = params_or_account + ) do + with {:ok, _code} <- validate_code(code), + {:ok, _name} <- validate_name(name), + {:ok, _description} <- validate_description(description), + {:ok, _active} <- validate_active_state(active), + {:ok, _classification} <- validate_classification(classification) do + {:ok, params_or_account} end end defp validate_params(_params), do: {:error, :invalid_params} - defp validate_update_params(%{name: _name} = params) - when is_map(params) and map_size(params) > 0 do - with {:ok, _} <- validate_name(params) do - params - |> Map.delete(:name) - |> maybe_validate_update_params() + defp validate_update_params(%{name: name} = params) do + with {:ok, _name} <- validate_name(name) do + maybe_reduce_update_params(params, :name) end end - defp validate_update_params(%{description: _description} = params) - when is_map(params) and map_size(params) > 0 do - with {:ok, _} <- validate_description(params) do - params - |> Map.delete(:description) - |> maybe_validate_update_params() + defp validate_update_params(%{description: description} = params) do + with {:ok, _description} <- validate_description(description) do + maybe_reduce_update_params(params, :description) end end - defp validate_update_params(%{active: _active} = params) - when is_map(params) and map_size(params) > 0 do - with {:ok, _} <- validate_active_state(params) do - params - |> Map.delete(:active) - |> maybe_validate_update_params() + defp validate_update_params(%{active: active} = params) do + with {:ok, _active} <- validate_active_state(active) do + maybe_reduce_update_params(params, :active) end end - defp validate_update_params(%{audit_details: _audit_details} = params) - when is_map(params) and map_size(params) > 0 do - with {:ok, _} <- validate_audit_details(params) do - params - |> Map.delete(:audit_details) - |> maybe_validate_update_params() - end + defp validate_update_params(%{audit_details: _audit_details} = params) do + maybe_reduce_update_params(params, :audit_details) end defp validate_update_params(_params), do: {:error, :invalid_params} - defp maybe_validate_update_params(params) when params == %{}, do: {:ok, %{}} - defp maybe_validate_update_params(params), do: validate_update_params(params) + defp maybe_reduce_update_params(params, field) do + params = Map.delete(params, field) - defp validate_code(%{code: code} = params) - when is_map(params) and is_binary(code) and code != "", - do: {:ok, params} + if params == %{}, + do: {:ok, params}, + else: validate_update_params(params) + end - defp validate_code(_params), do: {:error, :invalid_code} + defp validate_code(code) when is_binary(code) and code != "", do: {:ok, code} + defp validate_code(_code), do: {:error, :invalid_code} - defp validate_name(%{name: name} = params) - when is_map(params) and is_binary(name) and name != "", - do: {:ok, params} + defp validate_name(name) when is_binary(name) and name != "", do: {:ok, name} + defp validate_name(_name), do: {:error, :invalid_name} - defp validate_name(_params), do: {:error, :invalid_name} + defp validate_description(description) when is_binary(description), do: {:ok, description} + defp validate_description(_description), do: {:error, :invalid_description} - defp validate_description(%{description: description} = params) - when is_map(params) and is_binary(description), - do: {:ok, params} + defp validate_active_state(active) when is_boolean(active), do: {:ok, active} + defp validate_active_state(_active), do: {:error, :invalid_active_state} - defp validate_description(_params), do: {:error, :invalid_description} + defp validate_classification(classification) + when classification in @account_classifications, + do: {:ok, classification} - defp validate_active_state(%{active: active} = params) - when is_map(params) and is_boolean(active), - do: {:ok, params} + defp validate_classification(classification) + when is_struct(classification, __MODULE__.Classification), + do: {:ok, classification} - defp validate_active_state(_params), do: {:error, :invalid_active_state} + defp validate_classification(_classification), + do: {:error, :invalid_classification} - defp validate_classification({:ok, account}) when is_struct(account, __MODULE__) do - valid_classification? = - account - |> Map.get(:classification) - |> is_struct(__MODULE__.Classification) + defp validate_audit_details(params, action) do + details = Map.get(params, :audit_details, %{}) - if valid_classification?, - do: {:ok, account}, - else: {:error, :invalid_classification} + case AuditLog.create(%{ + record: "account", + action: action, + details: details + }) do + {:ok, audit_log} -> {:ok, audit_log} + {:error, _reason} -> {:error, :invalid_audit_details} + end end - defp validate_classification({:ok, %{classification: classification} = params}) - when is_binary(classification) and classification in @account_classifications, - do: {:ok, params} - - defp validate_classification({:error, reason}), do: {:error, reason} - defp validate_classification(_account_or_params), do: {:error, :invalid_classification} - - defp validate_audit_details({:ok, %{audit_details: audit_details} = params}) - when is_map(audit_details), - do: {:ok, params} - - defp validate_audit_details(%{audit_details: audit_details} = params) - when is_map(audit_details), - do: {:ok, params} - - defp validate_audit_details({:error, reason}), do: {:error, reason} - defp validate_audit_details(_params), do: {:error, :invalid_audit_details} - - defp validate_audit_logs({:ok, %{audit_logs: audit_logs} = account}) - when is_struct(account, __MODULE__) and is_list(audit_logs) and length(audit_logs) >= 1, - do: {:ok, account} - - defp validate_audit_logs({:error, reason}), do: {:error, reason} - defp validate_audit_logs(_account), do: {:error, :invalid_audit_logs} - - defp maybe_create( - {:ok, - %{ - code: code, - name: name, - description: description, - classification: classification, - audit_details: audit_details, - active: active - }} - ) do - {:ok, audit_log} = - AuditLog.create(%{ - record_type: "account", - action_type: "create", - audit_details: audit_details - }) - - classification = Classification.classify(classification) - - {:ok, - %__MODULE__{ - code: code, - name: name, - description: description, - classification: classification, - audit_logs: [audit_log], - active: active - }} - end + defp validate_audit_logs(%{audit_logs: audit_logs}) + when is_list(audit_logs) and length(audit_logs) >= 1, + do: {:ok, audit_logs} - defp maybe_create({:error, reason}), do: {:error, reason} + defp validate_audit_logs(_params), do: {:error, :invalid_audit_logs} end diff --git a/lib/bookkeeping/core/audit_log.ex b/lib/bookkeeping/core/audit_log.ex index 2b2d8e1..73177ce 100644 --- a/lib/bookkeeping/core/audit_log.ex +++ b/lib/bookkeeping/core/audit_log.ex @@ -5,14 +5,13 @@ defmodule Bookkeeping.Core.AuditLog do in the general ledger that is used to sort and store transactions. It is also used to track changes to records like accounts. """ - alias Bookkeeping.Core.AuditLog @typedoc """ t type is a struct that represents an audit log. """ @type t :: %__MODULE__{ - record_type: String.t(), - action_type: String.t(), + record: String.t(), + action: String.t(), details: map(), created_at: nil | integer(), updated_at: nil | integer(), @@ -23,86 +22,98 @@ defmodule Bookkeeping.Core.AuditLog do create_params type is a map that represents the params of the create function. """ @type create_params :: %{ - record_type: String.t(), - action_type: String.t(), - audit_details: map() + record: String.t(), + action: String.t(), + details: map() } - defstruct record_type: "", - action_type: "", + defstruct record: "", + action: "", details: %{}, created_at: nil, updated_at: nil, deleted_at: nil - @action_types ["create", "update", "delete"] + @records ~w(account journal_entry) + @actions ~w(create update delete) @doc """ Creates a new audit log struct. Arguments: - params: The params of the audit log. It must contain the following keys: - - record_type: The type of the record. - - action_type: The type of the action. - - audit_details: The details of the audit log. + - record: The type of the record. + - action: The type of the action. + - details: The details of the audit log. - Returns `{:ok, %AuditLog{}}` if the audit log is valid. Otherwise, returns `{:error, :invalid_field}` or `{:error, :invalid_params}`. + Returns `{:ok, %AuditLog{}}` if the audit log is valid. Otherwise, returns any of the following: + - `{:error, :invalid_record}` + - `{:error, :invalid_action}` + - `{:error, :invalid_details}` + - `{:error, :invalid_params}` ## Examples - iex> AuditLog.create(%{record_type: "account", action_type: "create", audit_details: %{email: "test@test.com"}}) + iex> AuditLog.create(%{record: "account", action: "create", details: %{email: "test@test.com"}}) {:ok, %AuditLog{...}} - iex> AuditLog.create(%{record_type: nil, action_type: "update", audit_details: %{}}) - {:error, :invalid_field} + iex> AuditLog.create(%{record: nil, action: "create", details: %{email: "test@test.com"}}) + {:error, :invalid_record} + + iex> AuditLog.create(%{record: "account", action: nil, details: %{email: "test@test.com"}}) + {:error, :invalid_action} + + iex> AuditLog.create(%{record: "account", action: "create", details: nil}) + {:error, :invalid_details} iex> AuditLog.create(nil) {:error, :invalid_params} """ @spec create(create_params()) :: - {:ok, AuditLog.t()} | {:error, :invalid_field | :invalid_params} + {:ok, __MODULE__.t()} + | {:error, + :invalid_record + | :invalid_action + | :invalid_details + | :invalid_params} def create(params) do - params |> validate_params() |> maybe_create() + with {:ok, params} <- validate_params(params) do + unix_datetime = DateTime.to_unix(DateTime.utc_now()) + created_at = if params.action == "create", do: unix_datetime, else: nil + deleted_at = if params.action == "delete", do: unix_datetime, else: nil + + {:ok, + %__MODULE__{ + record: params.record, + action: params.action, + details: params.details, + created_at: created_at, + updated_at: unix_datetime, + deleted_at: deleted_at + }} + end end defp validate_params( %{ - record_type: record_type, - action_type: action_type, - audit_details: audit_details - } = - params + record: record, + action: action, + details: details + } = params ) do - if is_binary(record_type) and record_type != "" and is_binary(action_type) and - action_type in @action_types and is_map(audit_details), - do: {:ok, params}, - else: {:error, :invalid_field} + with {:ok, _record} <- validate_record(record), + {:ok, _action} <- validate_action(action), + {:ok, _details} <- validate_details(details) do + {:ok, params} + end end defp validate_params(_), do: {:error, :invalid_params} - defp maybe_create( - {:ok, - %{ - record_type: record_type, - action_type: action_type, - audit_details: audit_details - }} - ) do - unix_datetime = DateTime.to_unix(DateTime.utc_now()) - created_at = if action_type == "create", do: unix_datetime, else: nil - deleted_at = if action_type == "delete", do: unix_datetime, else: nil - - {:ok, - %__MODULE__{ - record_type: record_type, - action_type: action_type, - details: audit_details, - created_at: created_at, - updated_at: unix_datetime, - deleted_at: deleted_at - }} - end - - defp maybe_create({:error, reason}), do: {:error, reason} + defp validate_record(record) when record in @records, do: {:ok, record} + defp validate_record(_record), do: {:error, :invalid_record} + defp validate_action(action) when action in @actions, do: {:ok, action} + defp validate_action(_action), do: {:error, :invalid_action} + defp validate_details(details) when is_map(details), do: {:ok, details} + defp validate_details(_details), do: {:error, :invalid_details} end diff --git a/lib/bookkeeping/core/line_item.ex b/lib/bookkeeping/core/line_item.ex index abf3d43..916d7ce 100644 --- a/lib/bookkeeping/core/line_item.ex +++ b/lib/bookkeeping/core/line_item.ex @@ -69,7 +69,9 @@ defmodule Bookkeeping.Core.LineItem do | :invalid_particulars | :invalid_params} def create(params) do - params |> validate_params() |> maybe_create() + with {:ok, params} <- validate_params(params) do + {:ok, Map.merge(%__MODULE__{}, params)} + end end @doc """ @@ -99,64 +101,46 @@ defmodule Bookkeeping.Core.LineItem do def validate(_), do: {:error, :invalid_line_item} - defp validate_params(params) when is_map(params) and map_size(params) > 0 do - with {:ok, _} <- validate_account(params), - {:ok, _} <- validate_amount(params), - {:ok, _} <- validate_entry(params), - {:ok, _} <- validate_particulars(params) do + defp validate_params( + %{ + account: account, + amount: amount, + entry: entry, + particulars: particulars + } = params + ) do + with {:ok, _account} <- validate_account(account), + {:ok, _amount} <- validate_amount(amount), + {:ok, _entry} <- validate_entry(entry), + {:ok, _particulars} <- validate_particulars(particulars) do {:ok, params} end end defp validate_params(_), do: {:error, :invalid_params} - defp maybe_create( - {:ok, - %{ - account: account, - amount: amount, - entry: entry, - particulars: particulars - }} - ) do - {:ok, - %__MODULE__{ - account: account, - amount: amount, - entry: entry, - particulars: particulars - }} - end - - defp maybe_create({:error, reason}), do: {:error, reason} - - defp validate_account(%{account: account} = params) when is_map(params) do + defp validate_account(account) do case Account.validate(account) do - {:ok, _account} -> {:ok, params} + {:ok, _account} -> {:ok, account} {:error, _reason} -> {:error, :invalid_account} end end - defp validate_account(_params), do: {:error, :invalid_account} - - defp validate_amount(%{amount: amount} = params) - when is_map(params) and is_struct(amount, Decimal) do + defp validate_amount(amount) when is_struct(amount, Decimal) do if Decimal.gt?(amount, Decimal.new(0)), - do: {:ok, params}, + do: {:ok, amount}, else: {:error, :invalid_amount} end - defp validate_amount(_params), do: {:error, :invalid_amount} + defp validate_amount(_amount), do: {:error, :invalid_amount} - defp validate_entry(%{entry: entry} = params) - when is_map(params) and entry in [:debit, :credit], - do: {:ok, params} + defp validate_entry(entry) when entry in [:debit, :credit], + do: {:ok, entry} - defp validate_entry(_params), do: {:error, :invalid_entry} + defp validate_entry(_entry), do: {:error, :invalid_entry} - defp validate_particulars(%{particulars: particulars} = params) - when is_map(params) and is_binary(particulars), - do: {:ok, params} + defp validate_particulars(particulars) when is_binary(particulars), + do: {:ok, particulars} - defp validate_particulars(_params), do: {:error, :invalid_particulars} + defp validate_particulars(_particulars), do: {:error, :invalid_particulars} end diff --git a/lib/bookkeeping/core/types.ex b/lib/bookkeeping/core/types.ex index a3f731d..0c422aa 100644 --- a/lib/bookkeeping/core/types.ex +++ b/lib/bookkeeping/core/types.ex @@ -1,4 +1,11 @@ defmodule Bookkeeping.Core.Types do + @moduledoc """ + Bookkeeping.Core.Types is a module that contains all the types used in the Bookkeeping.Core module. + There are core types: entry and category. + Entry is a type that indicates the type of accounting entry for a transaction (debit or credit). + Category is a type that indicates which of the two most important financial reports an account belongs to (position or performance). + """ + @typedoc """ Entry is a type that indicates the type of accounting entry for a transaction. It can have two possible values: debit or credit. diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs index 6af03df..eabe0bb 100644 --- a/test/benchmark/bookkeeping/core/account_benchmark.exs +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -15,9 +15,9 @@ defmodule Bookkeeping.Core.AccountBenchmark do "create/1 struct only" => fn -> audit_log = AuditLog.create(%{ - record_type: "account", - action_type: "create", - audit_details: %{} + record: "account", + action: "create", + details: %{} }) classification = Account.Classification.classify("asset") diff --git a/test/bookkeeping/boundary/chart_of_accounts_test.exs b/test/bookkeeping/boundary/chart_of_accounts_test.exs index ba29e80..00f49dc 100644 --- a/test/bookkeeping/boundary/chart_of_accounts_test.exs +++ b/test/bookkeeping/boundary/chart_of_accounts_test.exs @@ -61,9 +61,34 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert {:error, :invalid_params} = ChartOfAccounts.create(%{active: true}) end - test "with invalid field", %{invalid_params: invalid_params} do - params = update_params(invalid_params) - assert {:error, :invalid_field} = ChartOfAccounts.create(params) + test "with invalid code", %{params: params} do + params = params |> update_params() |> Map.put(:code, nil) + assert {:error, :invalid_code} = ChartOfAccounts.create(params) + end + + test "with invalid name", %{params: params} do + params = params |> update_params() |> Map.put(:name, nil) + assert {:error, :invalid_name} = ChartOfAccounts.create(params) + end + + test "with invalid classification", %{params: params} do + params = params |> update_params() |> Map.put(:classification, nil) + assert {:error, :invalid_classification} = ChartOfAccounts.create(params) + end + + test "with invalid description", %{params: params} do + params = params |> update_params() |> Map.put(:description, nil) + assert {:error, :invalid_description} = ChartOfAccounts.create(params) + end + + test "with invalid audit_details", %{params: params} do + params = params |> update_params() |> Map.put(:audit_details, nil) + assert {:error, :invalid_audit_details} = ChartOfAccounts.create(params) + end + + test "with invalid active state", %{params: params} do + params = params |> update_params() |> Map.put(:active, nil) + assert {:error, :invalid_active_state} = ChartOfAccounts.create(params) end test "that already exists", %{params: params} do @@ -122,9 +147,10 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert Enum.count(accounts) == 7 assert Enum.count(errors) == 3 - assert Enum.all?(errors, fn error -> - error.reason in [:already_exists, :invalid_field] - end) + assert Enum.all?( + errors, + &(&1.reason in [:already_exists, :invalid_name, :invalid_classification]) + ) end end @@ -147,7 +173,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert updated_account.description == "description updated" assert is_struct(updated_account.classification, Bookkeeping.Core.Account.Classification) assert is_list(updated_account.audit_logs) - assert length(updated_account.audit_logs) == 2 + assert length(updated_account.audit_logs) > 1 assert updated_account.active == false end @@ -156,18 +182,6 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert {:error, :invalid_account} = ChartOfAccounts.update("apple", %{name: "Cash updated"}) end - test "with invalid field", %{params: params} do - params = update_params(params) - {:ok, account} = ChartOfAccounts.create(params) - - assert {:error, :invalid_field} = ChartOfAccounts.update(account, %{code: "1001"}) - - assert {:error, :invalid_field} = - ChartOfAccounts.update(account, %{classification: "liability"}) - - assert {:error, :invalid_field} = ChartOfAccounts.update(account, %{test: "test"}) - end - test "with invalid params", %{params: params} do params = update_params(params) {:ok, account} = ChartOfAccounts.create(params) @@ -175,6 +189,11 @@ defmodule Bookkeeping.Boundary.ChartOfAccountsTest do assert {:error, :invalid_params} = ChartOfAccounts.update(account, nil) assert {:error, :invalid_params} = ChartOfAccounts.update(account, "apple") assert {:error, :invalid_params} = ChartOfAccounts.update(account, %{}) + assert {:error, :invalid_params} = ChartOfAccounts.update(account, %{code: "1001"}) + assert {:error, :invalid_params} = ChartOfAccounts.update(account, %{test: "test"}) + + assert {:error, :invalid_params} = + ChartOfAccounts.update(account, %{classification: "liability"}) end end diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index 5b00ee1..da68874 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -251,5 +251,10 @@ defmodule Bookkeeping.Core.AccountTest do account = Map.put(account, :audit_logs, nil) assert {:error, :invalid_account} = Account.validate(account) end + + test "with invalid params" do + assert {:error, :invalid_account} = Account.validate(nil) + assert {:error, :invalid_account} = Account.validate(%{}) + end end end diff --git a/test/bookkeeping/core/audit_log_test.exs b/test/bookkeeping/core/audit_log_test.exs index 717b0b2..b9dc86b 100644 --- a/test/bookkeeping/core/audit_log_test.exs +++ b/test/bookkeeping/core/audit_log_test.exs @@ -4,30 +4,35 @@ defmodule Bookkeeping.Core.AuditLogTest do setup do params = %{ - record_type: "account", - action_type: "create", - audit_details: %{email: "example@example.com"} + record: "account", + action: "create", + details: %{email: "example@example.com"} } - invalid_params = %{ - record_type: "account", - action_type: "invalid", - audit_details: %{} - } - - {:ok, params: params, invalid_params: invalid_params} + {:ok, params: params} end describe "create/1" do test "with valid params", %{params: params} do assert {:ok, audit_log} = AuditLog.create(params) - assert audit_log.record_type == "account" - assert audit_log.action_type == "create" + assert audit_log.record == "account" + assert audit_log.action == "create" assert audit_log.details == %{email: "example@example.com"} end - test "with invalid field", %{invalid_params: invalid_params} do - assert {:error, :invalid_field} = AuditLog.create(invalid_params) + test "with invalid record", %{params: params} do + params = Map.put(params, :record, nil) + assert {:error, :invalid_record} = AuditLog.create(params) + end + + test "with invalid action", %{params: params} do + params = Map.put(params, :action, nil) + assert {:error, :invalid_action} = AuditLog.create(params) + end + + test "with invalid details", %{params: params} do + params = Map.put(params, :details, nil) + assert {:error, :invalid_details} = AuditLog.create(params) end test "with invalid params" do diff --git a/test/bookkeeping/core/line_item_test.exs b/test/bookkeeping/core/line_item_test.exs index a9be133..5af8032 100644 --- a/test/bookkeeping/core/line_item_test.exs +++ b/test/bookkeeping/core/line_item_test.exs @@ -37,28 +37,28 @@ defmodule Bookkeeping.Core.LineItemTest do test "with invalid account", %{params: params} do params = Map.put(params, :account, %{}) assert {:error, :invalid_account} = LineItem.create(params) - params = Map.delete(params, :account) + params = Map.put(params, :account, nil) assert {:error, :invalid_account} = LineItem.create(params) end test "with invalid amount", %{params: params} do params = Map.put(params, :amount, 100) assert {:error, :invalid_amount} = LineItem.create(params) - params = Map.delete(params, :amount) + params = Map.put(params, :amount, nil) assert {:error, :invalid_amount} = LineItem.create(params) end test "with invalid entry type", %{params: params} do params = Map.put(params, :entry, "invalid") assert {:error, :invalid_entry} = LineItem.create(params) - params = Map.delete(params, :entry) + params = Map.put(params, :entry, nil) assert {:error, :invalid_entry} = LineItem.create(params) end test "with invalid particulars", %{params: params} do params = Map.put(params, :particulars, nil) assert {:error, :invalid_particulars} = LineItem.create(params) - params = Map.delete(params, :particulars) + params = Map.put(params, :particulars, nil) assert {:error, :invalid_particulars} = LineItem.create(params) end diff --git a/test/bookkeeping_test.exs b/test/bookkeeping_test.exs index 07571be..d492a50 100644 --- a/test/bookkeeping_test.exs +++ b/test/bookkeeping_test.exs @@ -42,6 +42,48 @@ defmodule BookkeepingTest do assert is_list(account.audit_logs) end + test "that already exists", %{params: params} do + params = update_params(params) + assert {:ok, _account} = Bookkeeping.create_account(params) + assert {:error, :already_exists} = Bookkeeping.create_account(params) + end + + test "with invalid code", %{params: params} do + params = update_params(params) + params = Map.put(params, :code, nil) + assert {:error, :invalid_code} = Bookkeeping.create_account(params) + end + + test "with invalid name", %{params: params} do + params = update_params(params) + params = Map.put(params, :name, nil) + assert {:error, :invalid_name} = Bookkeeping.create_account(params) + end + + test "with invalid classification", %{params: params} do + params = update_params(params) + params = Map.put(params, :classification, nil) + assert {:error, :invalid_classification} = Bookkeeping.create_account(params) + end + + test "with invalid description", %{params: params} do + params = update_params(params) + params = Map.put(params, :description, nil) + assert {:error, :invalid_description} = Bookkeeping.create_account(params) + end + + test "with invalid active state", %{params: params} do + params = update_params(params) + params = Map.put(params, :active, nil) + assert {:error, :invalid_active_state} = Bookkeeping.create_account(params) + end + + test "with invalid audit details", %{params: params} do + params = update_params(params) + params = Map.put(params, :audit_details, nil) + assert {:error, :invalid_audit_details} = Bookkeeping.create_account(params) + end + test "with invalid params" do assert {:error, :invalid_params} = Bookkeeping.create_account("apple") assert {:error, :invalid_params} = Bookkeeping.create_account(%{}) @@ -49,27 +91,10 @@ defmodule BookkeepingTest do assert {:error, :invalid_params} = Bookkeeping.create_account(%{name: "Cash"}) assert {:error, :invalid_params} = Bookkeeping.create_account(%{classification: "asset"}) assert {:error, :invalid_params} = Bookkeeping.create_account(%{description: "description"}) + assert {:error, :invalid_params} = Bookkeeping.create_account(%{active: true}) assert {:error, :invalid_params} = Bookkeeping.create_account(%{audit_details: %{email: "example@example.com"}}) - - assert {:error, :invalid_params} = Bookkeeping.create_account(%{active: true}) - end - - test "with invalid field", %{invalid_params: invalid_params} do - params = update_params(invalid_params) - assert {:error, :invalid_field} = Bookkeeping.create_account(params) - end - - test "that already exists", %{params: params} do - params = update_params(params) - assert {:ok, _account} = Bookkeeping.create_account(params) - assert {:error, :already_exists} = Bookkeeping.create_account(params) - end - - test "with invalid table" do - ChartOfAccounts.Worker.die() - assert {:error, :invalid_params} = Bookkeeping.create_account(%{code: "1000", name: "Cash"}) end end @@ -122,9 +147,10 @@ defmodule BookkeepingTest do assert Enum.count(accounts) == 7 assert Enum.count(errors) == 3 - assert Enum.all?(errors, fn error -> - error.reason in [:already_exists, :invalid_field] - end) + assert Enum.all?( + errors, + &(&1.reason in [:already_exists, :invalid_name, :invalid_classification]) + ) end end @@ -158,16 +184,32 @@ defmodule BookkeepingTest do Bookkeeping.update_account("apple", %{name: "Cash updated"}) end - test "with invalid field", %{params: params} do + test "with invalid name", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + assert {:error, :invalid_name} = Bookkeeping.update_account(account, %{name: nil}) + end + + test "with invalid description", %{params: params} do params = update_params(params) {:ok, account} = Bookkeeping.create_account(params) - assert {:error, :invalid_field} = Bookkeeping.update_account(account, %{code: "1001"}) + assert {:error, :invalid_description} = + Bookkeeping.update_account(account, %{description: nil}) + end + + test "with invalid audit details", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) - assert {:error, :invalid_field} = - Bookkeeping.update_account(account, %{classification: "liability"}) + assert {:error, :invalid_audit_details} = + Bookkeeping.update_account(account, %{audit_details: nil}) + end - assert {:error, :invalid_field} = Bookkeeping.update_account(account, %{test: "test"}) + test "with invalid active state", %{params: params} do + params = update_params(params) + {:ok, account} = Bookkeeping.create_account(params) + assert {:error, :invalid_active_state} = Bookkeeping.update_account(account, %{active: nil}) end test "with invalid params", %{params: params} do From d8ec1b0e6c29f52a4a5ffcfbea70fcbb3d2d3689 Mon Sep 17 00:00:00 2001 From: jeryldev Date: Thu, 28 Dec 2023 22:36:46 +0800 Subject: [PATCH 32/32] updated the line item and journal entry features --- .../boundary/accounting_journal/server.ex | 4 +- .../boundary/chart_of_accounts/manager.ex | 4 +- lib/bookkeeping/core/account.ex | 44 ++-- lib/bookkeeping/core/journal_entry.ex | 247 +++++++++++++++++- lib/bookkeeping/core/line_item.ex | 23 +- mix.exs | 1 + mix.lock | 7 + test/bookkeeping/core/journal_entry_test.exs | 92 +++++++ test/bookkeeping/core/line_item_test.exs | 4 +- 9 files changed, 371 insertions(+), 55 deletions(-) diff --git a/lib/bookkeeping/boundary/accounting_journal/server.ex b/lib/bookkeeping/boundary/accounting_journal/server.ex index 02c7161..fda95cd 100644 --- a/lib/bookkeeping/boundary/accounting_journal/server.ex +++ b/lib/bookkeeping/boundary/accounting_journal/server.ex @@ -39,7 +39,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do ...> } ...> }, ...> amount: Decimal.new(100), - ...> entry_type: :debit + ...> entry: :debit ...> }, ...> %LineItem{ ...> account: %Account{ @@ -53,7 +53,7 @@ defmodule Bookkeeping.Boundary.AccountingJournal.Server do ...> } ...> }, ...> amount: Decimal.new(100), - ...> entry_type: :credit + ...> entry: :credit ...> } ...> ], ...> audit_logs: [ diff --git a/lib/bookkeeping/boundary/chart_of_accounts/manager.ex b/lib/bookkeeping/boundary/chart_of_accounts/manager.ex index 9e8147b..c4e320a 100644 --- a/lib/bookkeeping/boundary/chart_of_accounts/manager.ex +++ b/lib/bookkeeping/boundary/chart_of_accounts/manager.ex @@ -32,7 +32,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do {:noreply, table} end - defp wait_for_worker() do + defp wait_for_worker do case Process.whereis(Worker) do nil -> Process.sleep(1) @@ -43,7 +43,7 @@ defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do end end - defp setup_table() do + defp setup_table do case Process.whereis(Worker) do nil -> Process.sleep(1) diff --git a/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index 1478cf5..4bda4de 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -280,16 +280,9 @@ defmodule Bookkeeping.Core.Account do | :invalid_audit_details} def create(params) do with {:ok, params} <- validate_params(params), - {:ok, audit_log} <- validate_audit_details(params, "create") do - {:ok, - %__MODULE__{ - code: params.code, - name: params.name, - description: params.description, - classification: Classification.classify(params.classification), - audit_logs: [audit_log], - active: params.active - }} + {:ok, params} <- validate_audit_details(params, "create") do + params = Map.put(params, :classification, Classification.classify(params.classification)) + {:ok, Map.merge(%__MODULE__{}, params)} end end @@ -349,8 +342,8 @@ defmodule Bookkeeping.Core.Account do def update(account, params) do with {:ok, _account} <- validate(account), {:ok, %{}} <- validate_update_params(params), - {:ok, audit_log} <- validate_audit_details(params, "update") do - params = Map.put(params, :audit_logs, [audit_log | account.audit_logs]) + {:ok, params} <- validate_audit_details(params, "update") do + params = Map.put(params, :audit_logs, [params.audit_logs | account.audit_logs]) {:ok, Map.merge(account, params)} end end @@ -381,7 +374,7 @@ defmodule Bookkeeping.Core.Account do end end - def validate(_), do: {:error, :invalid_account} + def validate(_account), do: {:error, :invalid_account} defp validate_params( %{ @@ -461,19 +454,24 @@ defmodule Bookkeeping.Core.Account do defp validate_audit_details(params, action) do details = Map.get(params, :audit_details, %{}) - case AuditLog.create(%{ - record: "account", - action: action, - details: details - }) do - {:ok, audit_log} -> {:ok, audit_log} - {:error, _reason} -> {:error, :invalid_audit_details} + case AuditLog.create(%{record: "account", action: action, details: details}) do + {:ok, audit_log} -> + params = + params + |> Map.delete(:audit_details) + |> Map.put(:audit_logs, [audit_log]) + + {:ok, params} + + {:error, _reason} -> + {:error, :invalid_audit_details} end end defp validate_audit_logs(%{audit_logs: audit_logs}) - when is_list(audit_logs) and length(audit_logs) >= 1, - do: {:ok, audit_logs} + when audit_logs == [] or not is_list(audit_logs), + do: {:error, :invalid_audit_logs} - defp validate_audit_logs(_params), do: {:error, :invalid_audit_logs} + defp validate_audit_logs(%{audit_logs: audit_logs}), + do: {:ok, audit_logs} end diff --git a/lib/bookkeeping/core/journal_entry.ex b/lib/bookkeeping/core/journal_entry.ex index 845b7dc..0e46925 100644 --- a/lib/bookkeeping/core/journal_entry.ex +++ b/lib/bookkeeping/core/journal_entry.ex @@ -8,25 +8,248 @@ defmodule Bookkeeping.Core.JournalEntry do @type t :: %__MODULE__{ transaction_date: DateTime.t(), - posting_date: DateTime.t(), - line_items: list(LineItem.t()), + posting_date: nil | DateTime.t(), document_number: String.t(), - reference_number: String.t(), - description: String.t(), - particulars: map(), + reference_number: nil | String.t(), + particulars: String.t(), + details: map(), + debit_items: list(LineItem.t()), + credit_items: list(LineItem.t()), audit_logs: list(AuditLog.t()), - posted: boolean() + posted: boolean(), + base_currency: String.t(), + transaction_currency: String.t(), + base_rate: Decimal.t(), + transaction_rate: Decimal.t() + } + + @type create_params :: %{ + transaction_date: DateTime.t(), + posting_date: nil | DateTime.t(), + document_number: String.t(), + reference_number: nil | String.t(), + particulars: String.t(), + details: map(), + debit_items: list(line_item_create_params()), + credit_items: list(line_item_create_params()), + audit_logs: list(AuditLog.t()), + posted: boolean(), + base_currency: String.t(), + transaction_currency: String.t(), + base_rate: Decimal.t(), + transaction_rate: Decimal.t() + } + + @type line_item_create_params :: %{ + account: Account.t(), + amount: integer() | float(), + particulars: String.t() } defstruct transaction_date: DateTime.utc_now(), - posting_date: DateTime.utc_now(), + posting_date: nil, document_number: "", - reference_number: "", - description: "", - particulars: %{}, - line_items: [], + reference_number: nil, + particulars: "", + details: %{}, + debit_items: [], + credit_items: [], audit_logs: [], - posted: false + posted: false, + base_currency: nil, + transaction_currency: nil, + base_rate: Decimal.new(1), + transaction_rate: Decimal.new(1) + + def create(params) do + with {:ok, params} <- validate_params(params), + {:ok, params} <- validate_audit_details(params, "create"), + {:ok, debit_items} <- validate_line_items(params, :debit_items), + {:ok, credit_items} <- validate_line_items(params, :credit_items), + {:ok, nil} <- validate_balance(debit_items, credit_items, params.transaction_currency) do + params = Map.merge(params, %{debit_items: debit_items, credit_items: credit_items}) + {:ok, Map.merge(%__MODULE__{}, params)} + end + end + + defp validate_params( + %{ + transaction_date: transaction_date, + posting_date: posting_date, + document_number: document_number, + reference_number: reference_number, + particulars: particulars, + details: details, + posted: posted, + base_currency: base_currency, + transaction_currency: transaction_currency, + base_rate: base_rate, + transaction_rate: transaction_rate + } = params + ) do + with {:ok, _transaction_date} <- validate_transaction_date(transaction_date), + {:ok, _posting_date} <- validate_posting_date(posting_date), + {:ok, _document_number} <- validate_binary(document_number, :invalid_document_number), + {:ok, _reference_number} <- validate_reference_number(reference_number), + {:ok, _particulars} <- validate_binary(particulars, :invalid_particulars), + {:ok, _details} <- validate_details(details), + {:ok, _posted} <- validate_posted_state(posted), + {:ok, _base_currency} <- validate_binary(base_currency, :invalid_base_currency), + {:ok, _transaction_currency} <- + validate_binary(transaction_currency, :invalid_transaction_currency), + {:ok, _base_rate} <- validate_rate(base_rate, :invalid_base_rate), + {:ok, _transaction_rate} <- validate_rate(transaction_rate, :invalid_base_rate) do + {:ok, params} + end + end + + defp validate_params(_), do: {:error, :invalid_params} + + defp validate_transaction_date(transaction_date) + when is_struct(transaction_date, DateTime), + do: {:ok, transaction_date} + + defp validate_transaction_date(_transaction_date), do: {:error, :invalid_transaction_date} + + defp validate_posting_date(posting_date) + when is_nil(posting_date) or is_struct(posting_date, DateTime), + do: {:ok, posting_date} + + defp validate_posting_date(_posting_date), do: {:error, :invalid_posting_date} + + defp validate_reference_number(reference_number) + when is_nil(reference_number) or (is_binary(reference_number) and reference_number != ""), + do: {:ok, reference_number} + + defp validate_reference_number(_reference_number), do: {:error, :invalid_reference_number} + + defp validate_binary(binary, _error_message) when is_binary(binary) and binary != "", + do: {:ok, binary} + + defp validate_binary(_binary, error_message), do: {:error, error_message} + + defp validate_rate(rate, _error_message) when is_struct(rate, Decimal), do: {:ok, rate} + defp validate_rate(_rate, error_message), do: {:error, error_message} + + defp validate_details(details) when is_map(details), do: {:ok, details} + defp validate_details(_details), do: {:error, :invalid_details} + + defp validate_posted_state(posted) when is_boolean(posted), do: {:ok, posted} + defp validate_posted_state(_posted), do: {:error, :invalid_posted_state} + + defp validate_audit_details(params, action) do + details = Map.get(params, :audit_details, %{}) + + case AuditLog.create(%{record: "journal_entry", action: action, details: details}) do + {:ok, audit_log} -> + params = + params + |> Map.delete(:audit_details) + |> Map.put(:audit_logs, [audit_log]) + + {:ok, params} + + {:error, _reason} -> + {:error, :invalid_audit_details} + end + end + + # defp validate_audit_logs(%{audit_logs: audit_logs}) + # when is_list(audit_logs) and length(audit_logs) >= 1, + # do: {:ok, audit_logs} + + # defp validate_audit_logs(_params), do: {:error, :invalid_audit_logs} + + # defp validate_posting_date(posting_date) when is_nil(posting_date) or is_datetime(posting_date), + # do: {:ok, posting_date} + + # defp validate_posting_date(_), do: {:error, :invalid_posting_date} + + # defp validate_document_number(document_number) + # when is_binary(document_number) and document_number != "", + # do: {:ok, document_number} + + # defp validate_date(datetime, _error_message) + # when is_struct(datetime, DateTime), + # do: {:ok, datetime} + + # defp validate_date(_datetime, error_message), do: {:error, error_message} + + defp validate_line_items(params, field) do + line_items = Map.get(params, field, []) + + if line_items == [] or not is_list(line_items) do + error_reason = + case field do + :debit_items -> :invalid_debit_items + :credit_items -> :invalid_credit_items + end + + {:error, error_reason} + else + transaction_currency = Map.get(params, :transaction_currency) + + Enum.reduce_while(line_items, {:ok, []}, fn item, acc -> + with {:ok, params} <- transform_amount(item, field, transaction_currency), + {:ok, line_item} <- LineItem.create(params) do + {:cont, {:ok, [line_item | elem(acc, 1)]}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end + + defp transform_amount(%{amount: amount} = params, field, transaction_currency) + when is_integer(amount) and field in [:debit_items, :credit_items] do + entry = if field == :debit_items, do: :debit, else: :credit + + case Money.new(transaction_currency, amount) do + {:error, _error} -> {:error, :invalid_amount} + amount -> {:ok, Map.merge(params, %{amount: amount, entry: entry})} + end + end + + defp transform_amount(%{amount: amount} = params, field, transaction_currency) + when is_float(amount) and field in [:debit_items, :credit_items] do + entry = if field == :debit_items, do: :debit, else: :credit + + case Money.from_float(transaction_currency, amount) do + {:error, _error} -> {:error, :invalid_amount} + amount -> {:ok, Map.merge(params, %{amount: amount, entry: entry})} + end + end + + defp transform_amount(_params, _field, _transaction_currency), do: {:error, :invalid_amount} + + # defp validate_currency(item, transaction_currency) do + # if Atom.to_string(item.amount.currency) == transaction_currency, + # do: {:ok, item}, + # else: {:error, :invalid_currency} + # end + + defp validate_balance(debit_items, credit_items, transaction_currency) do + total_debits = sum_amounts(debit_items, transaction_currency) + total_credits = sum_amounts(credit_items, transaction_currency) + + if Money.compare(total_debits, total_credits) == :eq, + do: {:ok, nil}, + else: {:error, :unbalanced_line_items} + end + + defp sum_amounts(line_items, transaction_currency) do + Enum.reduce(line_items, Money.new(transaction_currency, 0), fn item, acc -> + Money.add(acc, item.amount) |> elem(1) + end) + end + + # defp validate_date(datetime) do + # # format should be YYYY-MM-DD + # case Date.from_iso8601(datetime) do + # {:ok, _} -> true + # _ -> false + # end + # end # @type t_accounts :: %{ # left: list(LineItem.t()), diff --git a/lib/bookkeeping/core/line_item.ex b/lib/bookkeeping/core/line_item.ex index 916d7ce..95a0ba6 100644 --- a/lib/bookkeeping/core/line_item.ex +++ b/lib/bookkeeping/core/line_item.ex @@ -10,7 +10,7 @@ defmodule Bookkeeping.Core.LineItem do """ @type t :: %__MODULE__{ account: Account.t(), - amount: Decimal.t(), + amount: Money.t(), entry: Types.entry(), particulars: String.t() } @@ -42,22 +42,22 @@ defmodule Bookkeeping.Core.LineItem do iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", particulars: "", audit_details: %{}, active: true}) {:ok, asset_account} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :debit, particulars: ""}) + iex> LineItem.create(%{account: asset_account, amount: Money.new("USD", 100), entry: :debit, particulars: ""}) {:ok, %LineItem{...}} - iex> LineItem.create(%{account: nil, amount: Decimal.new(100), entry: :debit, particulars: ""}) + iex> LineItem.create(%{account: nil, amount: Money.new("USD", 100), entry: :debit, particulars: ""}) {:error, :invalid_account} iex> LineItem.create(%{account: asset_account, amount: 100, entry: :debit, particulars: ""}) {:error, :invalid_amount} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :invalid, particulars: ""}) + iex> LineItem.create(%{account: asset_account, amount: Money.new("USD", 100), entry: :invalid, particulars: ""}) {:error, :invalid_entry} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :debit, particulars: nil}) + iex> LineItem.create(%{account: asset_account, amount: Money.new("USD", 100), entry: :debit, particulars: nil}) {:error, :invalid_particulars} - iex> LineItem.create(%{account: asset_account, amount: Decimal.new(100), entry: :debit}) + iex> LineItem.create(%{account: asset_account, amount: Money.new("USD", 100), entry: :debit}) {:error, :invalid_params} """ @spec create(LineItem.t()) :: @@ -119,15 +119,10 @@ defmodule Bookkeeping.Core.LineItem do defp validate_params(_), do: {:error, :invalid_params} - defp validate_account(account) do - case Account.validate(account) do - {:ok, _account} -> {:ok, account} - {:error, _reason} -> {:error, :invalid_account} - end - end + defp validate_account(account), do: Account.validate(account) - defp validate_amount(amount) when is_struct(amount, Decimal) do - if Decimal.gt?(amount, Decimal.new(0)), + defp validate_amount(amount) when is_struct(amount, Money) do + if Money.compare(amount, Money.new(amount.currency, 0)) == :gt, do: {:ok, amount}, else: {:error, :invalid_amount} end diff --git a/mix.exs b/mix.exs index a0789d8..22ab2a3 100644 --- a/mix.exs +++ b/mix.exs @@ -36,6 +36,7 @@ defmodule Bookkeeping.MixProject do {:excoveralls, "~> 0.10", only: :test}, {:nimble_csv, "~> 1.2"}, {:jason, "~> 1.4"}, + {:ex_money, "~> 5.15"}, {:benchee, "~> 1.0", only: :dev} ] end diff --git a/mix.lock b/mix.lock index 794cb0f..4e432b2 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,19 @@ %{ "benchee": {:hex, :benchee, "1.2.0", "afd2f0caec06ce3a70d9c91c514c0b58114636db9d83c2dc6bfd416656618353", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "ee729e53217898b8fd30aaad3cce61973dab61574ae6f48229fe7ff42d5e4457"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "cldr_utils": {:hex, :cldr_utils, "2.24.2", "364fa30be55d328e704629568d431eb74cd2f085752b27f8025520b566352859", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3362b838836a9f0fa309de09a7127e36e67310e797d556db92f71b548832c7cf"}, "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, + "digital_token": {:hex, :digital_token, "0.6.0", "13e6de581f0b1f6c686f7c7d12ab11a84a7b22fa79adeb4b50eec1a2d278d258", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "2455d626e7c61a128b02a4a8caddb092548c3eb613ac6f6a85e4cbb6caddc4d1"}, + "ex_cldr": {:hex, :ex_cldr, "2.37.5", "9da6d97334035b961d2c2de167dc6af8cd3e09859301a5b8f49f90bd8b034593", [:mix], [{:cldr_utils, "~> 2.21", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "74ad5ddff791112ce4156382e171a5f5d3766af9d5c4675e0571f081fe136479"}, + "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.15.1", "e92ba17c41e7405b7784e0e65f406b5f17cfe313e0e70de9befd653e12854822", [:mix], [{:ex_cldr, "~> 2.34", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "31df8bd37688340f8819bdd770eb17d659652078d34db632b85d4a32864d6a25"}, + "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.32.3", "b631ff94c982ec518e46bf4736000a30a33d6b58facc085d5f240305f512ad4a", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.37", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, ">= 2.14.2", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "7b626ff1e59a0ec9c3c5db5ce9ca91a6995e2ab56426b71f3cbf67181ea225f5"}, + "ex_money": {:hex, :ex_money, "5.15.2", "660139ab73313c2c9ca9b7b4496bbee62e6b0ba87780d2a9af135e0e8a2c6feb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.31", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "59b96d83afa69070c595c9afd78ed0802e211c8741a5644e05ee44567f480845"}, "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, } diff --git a/test/bookkeeping/core/journal_entry_test.exs b/test/bookkeeping/core/journal_entry_test.exs index 2c38172..e9958d4 100644 --- a/test/bookkeeping/core/journal_entry_test.exs +++ b/test/bookkeeping/core/journal_entry_test.exs @@ -1,7 +1,99 @@ defmodule Bookkeeping.Core.JournalEntryTest do use ExUnit.Case, async: true + alias Bookkeeping.Core.LineItem + alias Bookkeeping.Core.AuditLog alias Bookkeeping.Core.{Account, JournalEntry} + setup do + {:ok, asset_account} = + Account.create(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: %{}, + active: true + }) + + {:ok, revenue_account} = + Account.create(%{ + code: "20_000", + name: "service revenue", + classification: "revenue", + description: "description", + audit_details: %{}, + active: true + }) + + {:ok, receivable_account} = + Account.create(%{ + code: "30_000", + name: "receivable", + classification: "asset", + description: "description", + audit_details: %{}, + active: true + }) + + params = %{ + transaction_date: DateTime.utc_now(), + posting_date: nil, + document_number: "JE100100", + reference_number: "INV100100", + particulars: "journal entry description", + details: %{approved_by: "example_admin@example.com"}, + debit_items: [ + %{ + account: asset_account, + amount: 99.05, + # amount: Money.new("USD", 100), + particulars: "cash from service revenue" + # entry: :debit + }, + %{ + account: receivable_account, + amount: 200.95, + # amount: Money.new("USD", 200), + particulars: "receivable from service revenue" + # entry: :debit + } + ], + credit_items: [ + %{ + account: revenue_account, + amount: 300, + # amount: Money.new("USD", 300), + particulars: "service revenue" + # entry: :credit + } + ], + audit_details: %{created_by: "example@example.com"}, + posted: false, + base_currency: "USD", + transaction_currency: "USD", + base_rate: Decimal.new("1"), + transaction_rate: Decimal.new("1") + } + + {:ok, asset_account: asset_account, revenue_account: revenue_account, params: params} + end + + describe "create/1" do + test "with valid unposted params", %{params: params} do + assert {:ok, journal_entry} = JournalEntry.create(params) + assert journal_entry.transaction_date == params.transaction_date + assert journal_entry.posting_date == params.posting_date + assert journal_entry.document_number == params.document_number + assert journal_entry.reference_number == params.reference_number + assert journal_entry.particulars == params.particulars + assert journal_entry.details == params.details + assert journal_entry.posted == params.posted + assert Enum.all?(journal_entry.audit_logs, &is_struct(&1, AuditLog)) + assert Enum.all?(journal_entry.debit_items, &is_struct(&1, LineItem)) + assert Enum.all?(journal_entry.credit_items, &is_struct(&1, LineItem)) + end + end + # setup do # transaction_date = DateTime.utc_now() # posting_date = DateTime.utc_now() diff --git a/test/bookkeeping/core/line_item_test.exs b/test/bookkeeping/core/line_item_test.exs index 5af8032..18dd537 100644 --- a/test/bookkeeping/core/line_item_test.exs +++ b/test/bookkeeping/core/line_item_test.exs @@ -16,7 +16,7 @@ defmodule Bookkeeping.Core.LineItemTest do params = %{ account: account, - amount: Decimal.new(100), + amount: Money.new("USD", 100), entry: :debit, particulars: "line particulars" } @@ -27,11 +27,11 @@ defmodule Bookkeeping.Core.LineItemTest do describe "create/1" do test "with valid params", %{params: params} do assert {:ok, line_item} = LineItem.create(params) - assert line_item.account == params.account assert line_item.amount == params.amount assert line_item.entry == params.entry assert line_item.particulars == params.particulars + assert is_struct(line_item.account, Account) end test "with invalid account", %{params: params} do