diff --git a/lib/bookkeeping.ex b/lib/bookkeeping.ex index 0f6b686..bec31f7 100644 --- a/lib/bookkeeping.ex +++ b/lib/bookkeeping.ex @@ -10,91 +10,166 @@ 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.Server, as: ChartOfAccounts - alias Bookkeeping.Core.{Account, JournalEntry} + alias Bookkeeping.Boundary.ChartOfAccounts.Worker, as: ChartOfAccounts + alias Bookkeeping.Core.Account ########################################################## # Chart of Accounts Functions # ########################################################## @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. - - Returns `{:ok, account}` if the account is valid, otherwise `{:error, :invalid_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.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(server, "1000", "Cash", "asset", "", %{}) + 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(server, "invalid", "invalid", nil, false, %{}) - {:error, :invalid_account} + 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: "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(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, + :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 """ - 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 `Code`, `Name`, `Classification`, `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: [%{reason: :already_exists, params: %{...}}, %{reason: :invalid_code, params: %{...}}, ...] }} - 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: + :already_exists + | :invalid_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params, + 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.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(%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(%Bookkeeping.Core.Account{...}, %{name: "Cash 1", description: "Cash and Cash Equivalents 1", audit_details: %{}, active: nil}) + {:error, :invalid_active_state} + + iex> Bookkeeping.update_account(%Bookkeeping.Core.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_name + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params} + def update_account(account, params), do: ChartOfAccounts.update(account, params) @doc """ Returns all accounts. @@ -107,39 +182,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 +230,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.) + # - 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.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(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`, `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_posting_date( + # DateTime.t() + # | AccountingJournal.posting_date_details() + # ) :: {:ok, list(JournalEntry.t())} | {:error, :invalid_date} + # defdelegate find_journal_entries_by_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_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_posting_date_range(%{year: 2021, month: 10, day: 10}, %{year: 2021, month: 10, day: 10}) + # {:ok, [%JournalEntry{...}]} + + # 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_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_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{...}, %{description: "updated description",posted: true}) + # {:ok, %JournalEntry{description: "updated description", posted: true, ...}} - Arguments: - - path: The path of the CSV file. + # iex> Bookkeeping.update_journal_entry(%JournalEntry{}, %{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 + # Returns `{:ok, state}`. - @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. + # ## Examples - Returns `{:ok, list(JournalEntry.t())}` if the journal entries are reset successfully. - - ## 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/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 281c21a..fda95cd 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{ @@ -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: [ @@ -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`, `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.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", "{}") - line_item_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, - line_item_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, - line_item_description: line_item_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, - line_item_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) - 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, "Account Name", "") - line_item_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) - - params = Map.put(initial_params, :t_accounts, updated_t_accounts) - - ok_params ++ [params] - - found_param -> - updated_t_accounts = - set_t_accounts(debit, credit, account, line_item_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, line_item_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 - } - - %{ - left: [t_accounts_debit_item] ++ params.t_accounts.left, - right: params.t_accounts.right - } - end - - defp set_t_accounts("" = _debit, credit, account, line_item_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 - } - - %{ - 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/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..c4e320a --- /dev/null +++ b/lib/bookkeeping/boundary/chart_of_accounts/manager.ex @@ -0,0 +1,70 @@ +defmodule Bookkeeping.Boundary.ChartOfAccounts.Manager do + use GenServer + + 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} + {:error, {:already_started, pid}} -> {:ok, pid} + error -> error + 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) + :ets.give_away(table, worker, data) + {:noreply, table} + end + + defp wait_for_worker do + case Process.whereis(Worker) do + nil -> + Process.sleep(1) + wait_for_worker() + + pid -> + pid + end + end + + defp 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 c40b533..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(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..9a557ee 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,19 @@ 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}} + @impl true 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/worker.ex b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex new file mode 100644 index 0000000..22d2c1e --- /dev/null +++ b/lib/bookkeeping/boundary/chart_of_accounts/worker.ex @@ -0,0 +1,461 @@ +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 + 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 + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + 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_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: + :already_exists + | :invalid_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params, + 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 + + @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_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}) + + @spec die() :: :ok + def die, do: GenServer.cast(__MODULE__, :die) + + @spec init(any()) :: {:ok, nil} + @impl true + def init(_), do: {:ok, nil} + + @impl true + def handle_info({:"ETS-TRANSFER", table, _pid, _data}, _table), do: {:noreply, table} + + @impl true + def handle_call({:create, params}, _from, table) do + account = maybe_handle_function(&create/2, table, [params]) + {:reply, account, table} + end + + @impl true + def handle_call({:update, account, params}, _from, table) do + 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 + 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 + 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 + :ets.insert(table, {account.code, account.name, account}) + {:ok, account} + end + end + + defp update(table, account, params) do + with {:ok, account} <- Account.update(account, params) do + :ets.insert(table, {account.code, account.name, account}) + {:ok, account} + end + end + + defp all_accounts(table) do + {:ok, :ets.select(table, [{{:_, :_, :"$1"}, [], [:"$1"]}])} + end + + defp check_similar_account(table, account) do + with {:ok, _account} <- match_code(table, account.code), + {:ok, _account} <- match_name(table, account.name) do + {:error, :already_exists} + end + end + + defp match_code(table, code) do + case :ets.lookup(table, code) do + [{_, _, account}] -> {:ok, account} + _ -> {:error, :not_found} + end + end + + 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 + + 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__) + + 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, "Code") + name = Map.get(csv_item, "Name") + classification = Map.get(csv_item, "Classification") + description = Map.get(csv_item, "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(params_list) when is_list(params_list) do + Enum.reduce(params_list, %{accounts: [], errors: []}, fn params, acc -> + case create(params) do + {:ok, account} -> %{acc | accounts: [account | acc.accounts]} + {:error, reason} -> %{acc | errors: [%{reason: reason, params: params} | acc.errors]} + end + end) + end + + defp bulk_create(error), do: error + + 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/lib/bookkeeping/core/account.ex b/lib/bookkeeping/core/account.ex index d32a0ab..4bda4de 100644 --- a/lib/bookkeeping/core/account.ex +++ b/lib/bookkeeping/core/account.ex @@ -6,8 +6,10 @@ defmodule Bookkeeping.Core.Account do """ alias Bookkeeping.Core.AuditLog + @typedoc """ + t type is a struct that represents an account. + """ @type t :: %__MODULE__{ - id: UUID.t(), code: account_code(), name: String.t(), description: String.t(), @@ -16,10 +18,36 @@ 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() - defstruct id: UUID.uuid4(), - code: "", + @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() + } + + @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: "", name: "", description: "", classification: nil, @@ -46,219 +74,404 @@ defmodule Bookkeeping.Core.Account do normal_balance: nil, category: nil, contra: false - end - - @doc """ - Creates a new account struct. - - 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. - - Returns `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`. - - ## Examples - - iex> Account.create("10_000", "cash", "asset", "", %{}) - {:ok, %Account{...}} - - 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} - end - end - - @doc """ - Updates an account struct. - - 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, returns `{:error, :invalid_account}`. - - ## Examples - - iex> {:ok, account} = Account.create("10_000", "cash", "asset") - - 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] - } - - {:ok, Map.merge(account, update_params)} - else - _ -> {:error, :invalid_account} - end - end - @doc """ - Validates an account struct. + @doc """ + Returns the classification struct. - Arguments: - - account: The account to be validated. + 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 `{:ok, %Account{}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`. + Returns `%Classification{}` if the classification is valid. Otherwise, returns `nil`. - ## Examples + ## Examples - iex> {:ok, account} = Account.create("10_000", "cash", "asset") + iex> Classification.classify("asset") + %Classification{...} - 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} - 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 - end - - defp accounts_classification do - %{ - "asset" => %Classification{ + iex> Classification.classify("invalid") + nil + """ + @spec classify(String.t()) :: __MODULE__.t() + def classify("asset") do + %Classification{ name: "Asset", normal_balance: :debit, category: :position, contra: false - }, - "liability" => %Classification{ + } + end + + def classify("liability") do + %Classification{ name: "Liability", normal_balance: :credit, category: :position, contra: false - }, - "equity" => %Classification{ + } + end + + def classify("equity") do + %Classification{ name: "Equity", normal_balance: :credit, category: :position, contra: false - }, - "revenue" => %Classification{ + } + end + + def classify("revenue") do + %Classification{ name: "Revenue", normal_balance: :credit, category: :performance, contra: false - }, - "expense" => %Classification{ + } + end + + def classify("expense") do + %Classification{ name: "Expense", normal_balance: :debit, category: :performance, contra: false - }, - "gain" => %Classification{ + } + end + + def classify("gain") do + %Classification{ name: "Gain", normal_balance: :credit, category: :performance, contra: false - }, - "loss" => %Classification{ + } + end + + def classify("loss") do + %Classification{ name: "Loss", normal_balance: :debit, category: :performance, contra: false - }, - "contra_asset" => %Classification{ + } + end + + def classify("contra_asset") do + %Classification{ name: "Contra Asset", normal_balance: :credit, category: :position, contra: true - }, - "contra_liability" => %Classification{ + } + end + + def classify("contra_liability") do + %Classification{ name: "Contra Liability", normal_balance: :debit, category: :position, contra: true - }, - "contra_equity" => %Classification{ + } + end + + def classify("contra_equity") do + %Classification{ name: "Contra Equity", normal_balance: :debit, category: :position, contra: true - }, - "contra_revenue" => %Classification{ + } + end + + def classify("contra_revenue") do + %Classification{ name: "Contra Revenue", normal_balance: :debit, category: :performance, contra: true - }, - "contra_expense" => %Classification{ + } + end + + def classify("contra_expense") do + %Classification{ name: "Contra Expense", normal_balance: :credit, category: :performance, contra: true - }, - "contra_gain" => %Classification{ + } + end + + def classify("contra_gain") do + %Classification{ name: "Contra Gain", normal_balance: :debit, category: :performance, contra: true - }, - "contra_loss" => %Classification{ + } + end + + def classify("contra_loss") do + %Classification{ name: "Contra Loss", normal_balance: :credit, category: :performance, contra: true } - } + end + + def classify(_), do: nil + end + + @doc """ + Creates a new account struct. + + 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, :invalid_params}` + - `{:error, :invalid_code}` + - `{:error, :invalid_name}` + - `{:error, :invalid_classification}` + - `{:error, :invalid_description}` + - `{:error, :invalid_audit_details}` + - `{:error, :invalid_active_state}` + + ## Examples + + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", description: "", audit_details: %{}, active: true}) + {:ok, %Account{...}} + + iex> Account.create([]) + {:error, :invalid_params} + + 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_code + | :invalid_name + | :invalid_classification + | :invalid_description + | :invalid_active_state + | :invalid_audit_details} + def create(params) do + with {:ok, params} <- validate_params(params), + {:ok, params} <- validate_audit_details(params, "create") do + params = Map.put(params, :classification, Classification.classify(params.classification)) + {:ok, Map.merge(%__MODULE__{}, params)} + end + end + + @doc """ + Updates an account struct. + + Arguments: + - account: The account to be updated. + - 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 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 + + iex> {:ok, account} = Account.create(params) + + iex> Account.update(account, %{name: "cash and cash equivalents", description: "cash and cash equivalents", audit_details: %{}, active: false}) + {:ok, %Account{...}} + + iex> Account.update(%{}, %{name: "cash and cash equivalents"}) + {:error, :invalid_account} + + iex> Account.update(account, %{name: nil}) + {: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_name + | :invalid_description + | :invalid_active_state + | :invalid_audit_details + | :invalid_params} + def update(account, params) do + with {:ok, _account} <- validate(account), + {:ok, %{}} <- validate_update_params(params), + {: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 + + @doc """ + Validates an account struct. + + Arguments: + - account: The account to be validated. + + Returns `{:ok, %Account{...}}` if the account is valid. Otherwise, returns `{:error, :invalid_account}`. + + ## Examples + + iex> Account.validate(account) + {:ok, %Account{...}} + + iex> Account.validate(%Account{}) + {:error, :invalid_account} + """ + @spec validate(Account.t()) :: {:ok, __MODULE__.t()} | {:error, :invalid_account} + def validate(account) when is_struct(account, __MODULE__) do + with {:ok, account} <- validate_params(account), + {:ok, _audit_logs} <- validate_audit_logs(account) do + {:ok, account} + else + _ -> {:error, :invalid_account} + end + end + + def validate(_account), do: {:error, :invalid_account} + + 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) do + with {:ok, _name} <- validate_name(name) do + maybe_reduce_update_params(params, :name) + end + end + + 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) 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) do + maybe_reduce_update_params(params, :audit_details) + end + + defp validate_update_params(_params), do: {:error, :invalid_params} + + defp maybe_reduce_update_params(params, field) do + params = Map.delete(params, field) + + if params == %{}, + do: {:ok, params}, + else: validate_update_params(params) + end + + 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) when is_binary(name) and name != "", do: {:ok, name} + defp validate_name(_name), 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_active_state(active) when is_boolean(active), do: {:ok, active} + defp validate_active_state(_active), do: {:error, :invalid_active_state} + + defp validate_classification(classification) + when classification in @account_classifications, + do: {:ok, classification} + + defp validate_classification(classification) + when is_struct(classification, __MODULE__.Classification), + do: {:ok, classification} + + defp validate_classification(_classification), + do: {:error, :invalid_classification} + + 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} -> + 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 audit_logs == [] or not is_list(audit_logs), + do: {:error, :invalid_audit_logs} + + defp validate_audit_logs(%{audit_logs: audit_logs}), + do: {:ok, audit_logs} end diff --git a/lib/bookkeeping/core/audit_log.ex b/lib/bookkeeping/core/audit_log.ex index 51b8363..73177ce 100644 --- a/lib/bookkeeping/core/audit_log.ex +++ b/lib/bookkeeping/core/audit_log.ex @@ -5,70 +5,115 @@ 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. """ + + @typedoc """ + t type is a struct that represents an audit log. + """ @type t :: %__MODULE__{ - id: UUID.t(), - record_type: String.t(), - action_type: String.t(), + record: String.t(), + action: String.t(), details: map(), created_at: nil | integer(), updated_at: nil | integer(), deleted_at: nil | integer() } - defstruct id: UUID.uuid4(), - record_type: "", - action_type: "", + @typedoc """ + create_params type is a map that represents the params of the create function. + """ + @type create_params :: %{ + record: String.t(), + action: String.t(), + details: map() + } + + 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. + Creates a new audit log struct. - Arguments: - - record_type: The type of the record. - - action_type: The type of the action. - - audit_details: The details of the audit log. + Arguments: + - params: The params of the audit log. It must contain the following keys: + - 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_audit_log}`. + 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 + ## Examples - iex> AuditLog.create("account", "create", %{email: "example@example.com"}) - {:ok, %AuditLog{...}} + iex> AuditLog.create(%{record: "account", action: "create", details: %{email: "test@test.com"}}) + {:ok, %AuditLog{...}} + iex> AuditLog.create(%{record: nil, action: "create", details: %{email: "test@test.com"}}) + {:error, :invalid_record} - iex> Audit.create("account", "update", %{email: "example@example.com"}) - {:ok, %AuditLog{...}} + iex> AuditLog.create(%{record: "account", action: nil, details: %{email: "test@test.com"}}) + {:error, :invalid_action} - iex> AuditLog.create("account", "delete", %{email: "example@example.com"}) - {:ok, %AuditLog{...}} + iex> AuditLog.create(%{record: "account", action: "create", details: nil}) + {:error, :invalid_details} - iex> AuditLog.create("account", "invalid", %{}) - {:error, :invalid_audit_log} + iex> AuditLog.create(nil) + {:error, :invalid_params} """ - @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 - 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 - }} + @spec create(create_params()) :: + {:ok, __MODULE__.t()} + | {:error, + :invalid_record + | :invalid_action + | :invalid_details + | :invalid_params} + def create(params) do + 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: record, + action: action, + details: details + } = params + ) do + with {:ok, _record} <- validate_record(record), + {:ok, _action} <- validate_action(action), + {:ok, _details} <- validate_details(details) do + {:ok, params} + end end - def create(_, _, _), do: {:error, :invalid_audit_log} + defp validate_params(_), do: {:error, :invalid_params} + + 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/journal_entry.ex b/lib/bookkeeping/core/journal_entry.ex index 2e813bd..0e46925 100644 --- a/lib/bookkeeping/core/journal_entry.ex +++ b/lib/bookkeeping/core/journal_entry.ex @@ -7,266 +7,490 @@ defmodule Bookkeeping.Core.JournalEntry do alias Bookkeeping.Core.{AuditLog, LineItem} @type t :: %__MODULE__{ - id: UUID.t(), transaction_date: DateTime.t(), - general_ledger_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(), + posting_date: nil | DateTime.t(), + document_number: String.t(), + 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 t_accounts :: %{ - left: list(LineItem.t()), - right: list(LineItem.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() } - 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: %{}, - line_items: [], + @type line_item_create_params :: %{ + account: Account.t(), + amount: integer() | float(), + particulars: String.t() + } + + defstruct transaction_date: DateTime.utc_now(), + posting_date: nil, + document_number: "", + 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) - @doc """ - Creates a new journal entry struct. + 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 - 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: 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.) - - audit_details: The details of the audit log. + 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 - 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)}`. + defp validate_params(_), do: {:error, :invalid_params} - ## Examples + defp validate_transaction_date(transaction_date) + when is_struct(transaction_date, DateTime), + do: {:ok, transaction_date} - 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: ""}] - }, "JE001001", "INV001001", "description", %{}, %{}) - {:ok, %JournalEntry{...}} + defp validate_transaction_date(_transaction_date), do: {:error, :invalid_transaction_date} - iex> JournalEntry.create(DateTime.utc_now(), "reference number", "description", %{}, %{}) - {:error, :invalid_journal_entry} + defp validate_posting_date(posting_date) + when is_nil(posting_date) or is_struct(posting_date, DateTime), + do: {:ok, 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, - general_ledger_posting_date, - t_accounts, - journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, - 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_map(t_accounts) and is_map(audit_details) and not is_nil(transaction_date) and - not is_nil(general_ledger_posting_date) - - if valid_fields? do - new( - transaction_date, - general_ledger_posting_date, - t_accounts, - journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, - audit_details - ) - else - {:error, :invalid_journal_entry} - end - end + defp validate_posting_date(_posting_date), do: {:error, :invalid_posting_date} - @doc """ - Updates a journal entry struct. Update can only be done if the journal entry is not posted. + defp validate_reference_number(reference_number) + when is_nil(reference_number) or (is_binary(reference_number) and reference_number != ""), + do: {:ok, reference_number} - 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`. + defp validate_reference_number(_reference_number), do: {:error, :invalid_reference_number} - Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`. + defp validate_binary(binary, _error_message) when is_binary(binary) and binary != "", + do: {:ok, binary} - ## Examples + defp validate_binary(_binary, error_message), do: {:error, error_message} - iex> JournalEntry.update(journal_entry, %{description: "updated description",posted: true}) - {:ok, %JournalEntry{...}} + defp validate_rate(rate, _error_message) when is_struct(rate, Decimal), do: {:ok, rate} + defp validate_rate(_rate, error_message), do: {:error, error_message} - iex> JournalEntry.update(journal_entry, %{transaction_date: DateTime.utc_now()}) - {:error, :already_posted_journal_entry} + defp validate_details(details) when is_map(details), do: {:ok, details} + defp validate_details(_details), do: {:error, :invalid_details} - 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("journal_entry", "update", params.audit_details), - {:ok, initial_je_update} <- - update_dates_and_line_items( - journal_entry, - params.transaction_date, - params.general_ledger_posting_date, - params.t_accounts - ), - {:ok, final_je_update} <- - update_other_journal_entry_details( - initial_je_update, - params.journal_entry_number, - params.transaction_reference_number, - params.journal_entry_description, - params.journal_entry_details, - audit_log, - params.posted - ) do - {:ok, final_je_update} - else - _ -> {:error, :invalid_journal_entry} + 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 - def update(journal_entry, _) when journal_entry.posted == true, - do: {:error, :already_posted_journal_entry} + # defp validate_audit_logs(%{audit_logs: audit_logs}) + # when is_list(audit_logs) and length(audit_logs) >= 1, + # do: {:ok, audit_logs} - def update(_, _), do: {:error, :invalid_journal_entry} + # defp validate_audit_logs(_params), do: {:error, :invalid_audit_logs} - defp new( - transaction_date, - general_ledger_posting_date, - t_accounts, - journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, - audit_details - ) do - with {:ok, line_items} <- LineItem.bulk_create(t_accounts), - {:ok, audit_log} <- AuditLog.create("journal_entry", "create", audit_details) do - {:ok, - %__MODULE__{ - id: UUID.uuid4(), - transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_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, - audit_logs: [audit_log] - }} + # 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 - {:error, message} -> {:error, message} + 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 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_boolean(params.posted) and is_map(params.t_accounts) and is_map(params.audit_details) + 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 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), - 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), - 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} + 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 update_dates_and_line_items( - journal_entry, - transaction_date, - general_ledger_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, - general_ledger_posting_date: general_ledger_posting_date, - line_items: line_items - } - - {:ok, Map.merge(journal_entry, update_params)} + 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 update_other_journal_entry_details( - journal_entry, - journal_entry_number, - transaction_reference_number, - journal_entry_description, - journal_entry_details, - 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, - audit_logs: [audit_log | existing_audit_logs], - posted: posted - } - - {:ok, Map.merge(journal_entry, update_params)} + 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()), + # 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. + + # 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 + + # 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} + + # """ + # @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 + + # @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`. + + # Returns `{:ok, %JournalEntry{}}` if the journal entry is valid. Otherwise, returns `{:error, :invalid_journal_entry}`. + + # ## Examples + + # 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(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(_, _), 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__{ + # 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_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 + + # 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 + # } + + # {: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, []) + + # 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 end diff --git a/lib/bookkeeping/core/line_item.ex b/lib/bookkeeping/core/line_item.ex index 0a45d5c..95a0ba6 100644 --- a/lib/bookkeeping/core/line_item.ex +++ b/lib/bookkeeping/core/line_item.ex @@ -5,185 +5,137 @@ defmodule Bookkeeping.Core.LineItem do """ 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(), - entry_type: Types.entry(), - line_item_description: String.t() - } - - @type t_accounts :: %{ - left: list(account_amount_pair()), - right: list(account_amount_pair()) + amount: Money.t(), + entry: Types.entry(), + particulars: String.t() } - @type account_amount_pair :: %{ - account: Account.t(), - amount: Decimal.t(), - line_item_description: String.t() - } - - defstruct account: %Account{}, + defstruct account: nil, amount: 0, - entry_type: nil, - line_item_description: "" + entry: nil, + particulars: "" @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. - - Returns `{:ok, [%LineItem{}, ...]}` if the line item is valid. Otherwise, returns `{:error, :invalid_line_items}`. + - 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: 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 any of the following: + - `{:error, :invalid_account}` + - `{:error, :invalid_amount}` + - `{:error, :invalid_entry}` + - `{:error, :invalid_particulars}` + - `{:error, :invalid_params}`. ## 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: ""}]}) - {:ok, [%LineItem{...}, %LineItem{...}]} + iex> Account.create(%{code: "10_000", name: "cash", classification: "asset", particulars: "", 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: Money.new("USD", 100), entry: :debit, particulars: ""}) + {:ok, %LineItem{...}} - def bulk_create(_), do: {:error, :invalid_line_items} + iex> LineItem.create(%{account: nil, amount: Money.new("USD", 100), entry: :debit, particulars: ""}) + {: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`. - - line_item_description (optional): The description of the line item. + iex> LineItem.create(%{account: asset_account, amount: 100, entry: :debit, particulars: ""}) + {: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: Money.new("USD", 100), entry: :invalid, particulars: ""}) + {:error, :invalid_entry} - ## Examples + iex> LineItem.create(%{account: asset_account, amount: Money.new("USD", 100), entry: :debit, particulars: nil}) + {:error, :invalid_particulars} - iex> LineItem.create(account_amount_pair(), :debit) - {:ok, %LineItem{...}} + iex> LineItem.create(%{account: asset_account, amount: Money.new("USD", 100), entry: :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 - line_item_description = Map.get(account_amount_pair, :line_item_description, "") - - {:ok, - %__MODULE__{ - account: account, - amount: amount, - entry_type: entry_type, - line_item_description: line_item_description - }} - else - {:error, message} -> {:error, message} - _ -> {:error, :invalid_line_items} + | {:error, + :invalid_account + | :invalid_amount + | :invalid_entry + | :invalid_particulars + | :invalid_params} + def create(params) do + with {:ok, params} <- validate_params(params) do + {:ok, Map.merge(%__MODULE__{}, params)} 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 + @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 - 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) + iex> LineItem.validate(line_item) + {:ok, %LineItem{...}} - 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} + 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, __MODULE__) do + case validate_params(line_item) do + {:ok, _line_item} -> {:ok, line_item} + {:error, _reason} -> {:error, :invalid_line_item} 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} + def validate(_), do: {:error, :invalid_line_item} + + 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_account(account) - when is_struct(account, Account) and account.active, - do: {:ok, account} + defp validate_params(_), do: {:error, :invalid_params} - defp validate_account(_), do: {:error, :invalid_account} + 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 - defp validate_amount(_), do: {:error, :invalid_amount} - - defp validate_entry_type(:debit), do: {:ok, :debit} - defp validate_entry_type(:credit), do: {:ok, :credit} + defp validate_amount(_amount), do: {:error, :invalid_amount} - defp process_line_item(acc, line_item) do - entry_type = line_item.entry_type + defp validate_entry(entry) when entry in [:debit, :credit], + do: {:ok, entry} - updated_debit_balance = - if entry_type == :debit, - do: Decimal.add(acc.debit_balance, line_item.amount), - else: acc.debit_balance + defp validate_entry(_entry), do: {:error, :invalid_entry} - updated_credit_balance = - if entry_type == :credit, - do: Decimal.add(acc.credit_balance, line_item.amount), - else: acc.credit_balance + defp validate_particulars(particulars) when is_binary(particulars), + do: {:ok, particulars} - %{ - 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_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/lib/bookkeeping/data/sample_chart_of_accounts.csv b/lib/bookkeeping/data/sample_chart_of_accounts.csv index 76803a0..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,Account Type,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/mix.exs b/mix.exs index 034a9a3..22ab2a3 100644 --- a/mix.exs +++ b/mix.exs @@ -33,10 +33,10 @@ 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"}, + {:ex_money, "~> 5.15"}, {:benchee, "~> 1.0", only: :dev} ] end diff --git a/mix.lock b/mix.lock index 456f860..4e432b2 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +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"}, - "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"}, + "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"}, - "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"}, + "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"}, - "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, } diff --git a/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs new file mode 100644 index 0000000..36ac1b8 --- /dev/null +++ b/test/benchmark/bookkeeping/boundary/chart_of_accounts_benchmark.exs @@ -0,0 +1,112 @@ +defmodule Bookkeeping.Boundary.ChartOfAccountsBenchmark do + alias Bookkeeping.Boundary.ChartOfAccounts.Supervisor, as: ChartOfAccountsSupervisor + alias Bookkeeping.Boundary.ChartOfAccounts.Worker + + ChartOfAccountsServer.start_link() + 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 -> + 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 diff --git a/test/benchmark/bookkeeping/core/account_benchmark.exs b/test/benchmark/bookkeeping/core/account_benchmark.exs new file mode 100644 index 0000000..eabe0bb --- /dev/null +++ b/test/benchmark/bookkeeping/core/account_benchmark.exs @@ -0,0 +1,72 @@ +defmodule Bookkeeping.Core.AccountBenchmark do + alias Bookkeeping.Core.{Account, AuditLog} + + Benchee.run(%{ + "create/1" => fn -> + Account.create(%{ + code: "1000", + name: "Cash 0", + classification: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + }) + end, + "create/1 struct only" => fn -> + audit_log = + AuditLog.create(%{ + record: "account", + action: "create", + details: %{} + }) + + classification = Account.Classification.classify("asset") + + struct(%Account{}, %{ + code: "1000", + name: "Cash 0", + type: classification, + description: "Cash and Cash Equivalents 0", + audit_details: [audit_log], + 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_2} = + Account.create(%{ + code: "1000", + name: "Cash 0", + classification: "asset", + description: "Cash and Cash Equivalents 0", + audit_details: %{}, + active: true + }) + + Benchee.run(%{ + "update/2" => fn -> + random_string = for _ <- 1..10, into: "", do: <> + + Account.update(cash_account_2, %{ + code: random_string, + name: random_string, + classification: "asset", + description: "Cash and Cash Equivalents 2", + audit_details: %{email: "test@test.com"}, + active: false + }) + end + }) +end diff --git a/test/bookkeeping/boundary/accounting_journal_test.exs b/test/bookkeeping/boundary/accounting_journal_test.exs index 298e049..06df4c4 100644 --- a/test/bookkeeping/boundary/accounting_journal_test.exs +++ b/test/bookkeeping/boundary/accounting_journal_test.exs @@ -2,15 +2,15 @@ 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 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} = @@ -41,42 +41,42 @@ 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" } ] } - 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,25 +404,25 @@ 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") - 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 @@ -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/boundary/chart_of_accounts_test.exs b/test/bookkeeping/boundary/chart_of_accounts_test.exs index 21cb31d..00f49dc 100644 --- a/test/bookkeeping/boundary/chart_of_accounts_test.exs +++ b/test/bookkeeping/boundary/chart_of_accounts_test.exs @@ -1,326 +1,314 @@ 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.ChartOfAccounts.Worker, as: ChartOfAccounts 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() + 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, params: params, invalid_params: invalid_params} 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" + describe "Worker start_link/1 " do + test "with valid params" do + {:ok, server} = ChartOfAccounts.start_link([]) + assert server in Process.list() + end - 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 - ) + test "with invalid params" do + {:ok, _server} = ChartOfAccounts.start_link(test: nil) + end 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" - ) + describe "create/1" do + test "with valid params", %{params: params} do + params = update_params(params) + assert {:ok, account} = ChartOfAccounts.create(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} = 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 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 + params = update_params(params) + assert {:ok, _account} = ChartOfAccounts.create(params) + assert {:error, :already_exists} = ChartOfAccounts.create(params) + end + end - # importing an invalid or missing file - assert {:error, :invalid_file} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/invalid_file.csv" - ) + describe "import/1" do + test "with a valid file twice" do + assert %{accounts: accounts, errors: _errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" + ) - # importing accounts with empty fields - assert {:error, %{message: :invalid_csv, errors: _errors}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/invalid_chart_of_accounts.csv" - ) + assert length(accounts) == 9 - # importing an empty file - assert {:error, :invalid_file} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/empty_chart_of_accounts.csv" - ) + Process.sleep(300) - # 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" - ) + assert %{accounts: [], errors: errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/valid_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" - ) + assert Enum.count(errors) == 9 + assert Enum.all?(errors, fn error -> error.reason == :already_exists end) - # importing the file twice - assert {:error, %{ok: _oks, error: _errors}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/duplicate_chart_of_accounts.csv" - ) + assert %{accounts: [], errors: _errors} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/empty_chart_of_accounts_2.csv" + ) + end - # importing a partially valid file - assert {:ok, %{error: errors, ok: oks}} = - ChartOfAccountsServer.import_accounts( - "../../../../test/bookkeeping/data/partially_valid_chart_of_accounts.csv" - ) + test "with an invalid file" do + assert {:error, :invalid_file} = + ChartOfAccounts.import_file("../../../../test/bookkeeping/data/invalid_file.csv") - assert errors == [ - %{ - error: :account_already_exists, - account_code: "1000001012", - account_name: "Accounts Receivable Bulk Test 2" - } - ] + assert {:error, :invalid_file} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/empty_chart_of_accounts.csv" + ) - assert Enum.count(oks) == 8 - end + assert {:error, :invalid_file} = + ChartOfAccounts.import_file("../../../../test/bookkeeping/data/text_file.txt") - test "update account" do - assert {:ok, account} = - ChartOfAccountsServer.create_account( - "1000update", - "Cash original", - "asset", - "", - %{} - ) + assert {:error, :invalid_file} = ChartOfAccounts.import_file(nil) + end - assert {:ok, updated_account} = - ChartOfAccountsServer.update_account(account, %{name: "Cash updated"}) + 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 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 Enum.count(accounts) == 7 + assert Enum.count(errors) == 3 - 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 + assert Enum.all?( + errors, + &(&1.reason in [:already_exists, :invalid_name, :invalid_classification]) + ) + end end - test "all accounts" do - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - assert is_list(accounts) + describe "update/2" do + test "with valid params", %{params: params} do + params = update_params(params) + {: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 == 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) > 1 + 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 params", %{params: params} do + params = update_params(params) + {: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, %{}) + 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 - 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) + 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 - 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) + 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) + 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) + + code_prefix = String.slice(account.code, 0, 2) + assert {:ok, accounts} = ChartOfAccounts.search_code(code_prefix) + 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 "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 "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) + 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) + 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 + assert {:error, :invalid_name} = ChartOfAccounts.search_name(nil) + assert {:error, :invalid_name} = ChartOfAccounts.search_name(%{}) + assert {:error, :invalid_name} = ChartOfAccounts.search_name("") + end end - test "get all sorted accounts by code or name" do - assert {:ok, account_1} = - ChartOfAccountsServer.create_account("1001000", "Cash4", "asset", "", %{}) + 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) - assert {:ok, account_2} = - ChartOfAccountsServer.create_account("1002000", "Receivable4", "asset", "", %{}) + ChartOfAccounts.die() - assert {:ok, account_3} = - ChartOfAccountsServer.create_account("1003000", "Inventory4", "asset", "", %{}) + assert {:ok, accounts} = ChartOfAccounts.search_code(account.code) + assert account in accounts + end - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - assert Enum.member?(accounts, account_1) - assert Enum.member?(accounts, account_2) - assert Enum.member?(accounts, account_3) + 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() + params = update_params(params) + assert {:ok, account} = ChartOfAccounts.create(params) + assert is_struct(account) - 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 + 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 - 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 + ChartOfAccounts.die() + assert {:ok, updated_account} = ChartOfAccounts.update(account, %{name: "Cash updated"}) + assert updated_account.code == account.code + assert updated_account.name == "Cash updated" - assert {:error, :invalid_field} = ChartOfAccountsServer.all_sorted_accounts("invalid") - 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", - "", - %{} - ) + ChartOfAccounts.die() - 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 %{accounts: accounts, errors: []} = + ChartOfAccounts.import_file( + "../../../../test/bookkeeping/data/valid_chart_of_accounts_2.csv" + ) - assert {:ok, []} = ChartOfAccountsServer.reset_accounts() + assert length(accounts) == 9 - assert {:ok, accounts} = ChartOfAccountsServer.all_accounts() - refute Enum.member?(accounts, account_1) - refute Enum.member?(accounts, account_2) - refute Enum.member?(accounts, account_3) + ChartOfAccounts.die() + assert {:ok, _accounts} = ChartOfAccounts.all_accounts() + end end - test "get chart of accounts state" do - assert {:ok, state} = ChartOfAccountsServer.get_chart_of_accounts_state() - assert is_map(state) == true + defp update_params(params) do + code = random_string() + name = random_string() + Map.merge(params, %{code: code, name: name}) 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, %{}) + defp random_string do + for _ <- 1..10, into: "", do: <> end - - defp find_account_index(accounts, code), do: Enum.find_index(accounts, &(&1.code == code)) end diff --git a/test/bookkeeping/core/account_test.exs b/test/bookkeeping/core/account_test.exs index 708421a..da68874 100644 --- a/test/bookkeeping/core/account_test.exs +++ b/test/bookkeeping/core/account_test.exs @@ -3,89 +3,258 @@ 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 - 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) + 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 - assert new_account.code == "10_000" - assert new_account.name == "cash" - assert new_account.classification.name == "Asset" - assert new_account.classification.normal_balance == :debit + liability = Account.Classification.classify("liability") + assert is_struct(liability) + assert liability.name == "Liability" + assert liability.normal_balance == :credit - assert {:ok, _valid_account} = Account.validate_account(new_account) - end + equity = Account.Classification.classify("equity") + assert is_struct(equity) + assert equity.name == "Equity" + assert equity.normal_balance == :credit - 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) + revenue = Account.Classification.classify("revenue") + assert is_struct(revenue) + assert revenue.name == "Revenue" + assert revenue.normal_balance == :credit - 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 + expense = Account.Classification.classify("expense") + assert is_struct(expense) + assert expense.name == "Expense" + assert expense.normal_balance == :debit - test "disallow non-binary code field", %{details: details} do - new_account = Account.create(10_000, "cash", "asset", "description", details) + gain = Account.Classification.classify("gain") + assert is_struct(gain) + assert gain.name == "Gain" + assert gain.normal_balance == :credit - assert ^new_account = {:error, :invalid_account} - end + loss = Account.Classification.classify("loss") + assert is_struct(loss) + assert loss.name == "Loss" + assert loss.normal_balance == :debit - test "disallow non-binary name field", %{details: details} do - new_account = Account.create(10_000, 10_000, "asset", "description", details) + contra_asset = Account.Classification.classify("contra_asset") + assert is_struct(contra_asset) + assert contra_asset.name == "Contra Asset" + assert contra_asset.normal_balance == :credit - assert ^new_account = {:error, :invalid_account} - end + 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 - test "disallow non-%AccountClassification{} account field", %{details: details} do - new_account = Account.create(10_000, "cash", "classification", "description", details) + contra_gain = Account.Classification.classify("contra_gain") + assert is_struct(contra_gain) + assert contra_gain.name == "Contra Gain" + assert contra_gain.normal_balance == :debit - assert ^new_account = {:error, :invalid_account} + 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 - test "disallow empty name", %{details: details} do - new_account = Account.create(10_000, "", "asset", "description", details) + describe "create/1" do + 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" + 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) + 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 - assert ^new_account = {:error, :invalid_account} + 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 - test "update account", %{details: details} do - assert {:ok, account} = - Account.create("10_000", "cash", "asset", "description", details) - - 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: ""}) - - assert {:ok, account_3} = - Account.update(account, %{ - code: "10_001", - name: "trade payables", - classification: "liability" - }) - - assert account.code == account_3.code - refute account.name == account_3.name - assert account.classification == account_3.classification - - assert {:ok, _account_4} = - Account.update(account, %{ - code: "10_001", - name: "cash and cash equivalents" - }) + describe "update/2" do + test "with valid params", %{params: params} do + assert {:ok, account} = Account.create(params) + + 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 + 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 name", %{params: params} do + assert {:ok, account} = Account.create(params) + assert {:error, :invalid_name} = Account.update(account, %{name: nil}) + end + + test "with invalid description", %{params: params} do + assert {:ok, account} = Account.create(params) + assert {:error, :invalid_description} = Account.update(account, %{description: nil}) + end + + 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 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 - test "validate account" do - assert {:error, :invalid_account} = Account.validate_account(%Account{}) + 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 + + 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 f86b042..b9dc86b 100644 --- a/test/bookkeeping/core/audit_log_test.exs +++ b/test/bookkeeping/core/audit_log_test.exs @@ -3,49 +3,40 @@ defmodule Bookkeeping.Core.AuditLogTest do alias Bookkeeping.Core.AuditLog setup do - details = %{email: "example@example.com"} - {:ok, details: details} - end + params = %{ + record: "account", + action: "create", + 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) + {:ok, params: params} end - 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) - end + describe "create/1" do + test "with valid params", %{params: params} do + assert {:ok, audit_log} = AuditLog.create(params) + assert audit_log.record == "account" + assert audit_log.action == "create" + assert audit_log.details == %{email: "example@example.com"} + 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 + 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 "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 diff --git a/test/bookkeeping/core/journal_entry_test.exs b/test/bookkeeping/core/journal_entry_test.exs index bd6a604..e9958d4 100644 --- a/test/bookkeeping/core/journal_entry_test.exs +++ b/test/bookkeeping/core/journal_entry_test.exs @@ -1,281 +1,373 @@ 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 - transaction_date = DateTime.utc_now() - general_ledger_posting_date = DateTime.utc_now() - journal_entry_number = "JE100100" - transaction_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(%{ + code: "10_000", + name: "cash", + classification: "asset", + description: "description", + audit_details: %{}, + active: true + }) {:ok, revenue_account} = - Account.create( - "20000", - "service revenue", - "revenue", - "journal_entry_description", - audit_details - ) - - t_accounts = %{ - left: [ + 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: Decimal.new(100), - line_item_description: "cash from service revenue" + 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 } ], - right: [ + credit_items: [ %{ account: revenue_account, - amount: Decimal.new(100), - line_item_description: "service revenue" + 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") } - journal_entry_details = %{approved_by: "example@example.com"} - - {:ok, - transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_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, - audit_details: audit_details} + {:ok, asset_account: asset_account, revenue_account: revenue_account, params: params} end - test "create a journal entry", %{ - 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_details: journal_entry_details, - audit_details: audit_details - } do - assert {:ok, _journal_entry} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - t_accounts, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) + 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 - test "disallow journal entry with invalid t_accounts", %{ - transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_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, - audit_details: audit_details - } do - assert {:error, [:invalid_account]} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - %{ - left: [%{account: "revenue_account", amount: Decimal.new(100)}], - right: [%{account: asset_account, amount: Decimal.new(100)}] - }, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - - assert {:error, [:invalid_account]} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - %{ - left: [%{account: revenue_account, amount: Decimal.new(100)}], - right: [%{account: "asset_account", amount: Decimal.new(100)}] - }, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - - assert {:error, :unbalanced_line_items} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - %{ - left: [%{account: revenue_account, amount: Decimal.new(100)}], - right: [%{account: asset_account, amount: Decimal.new(200)}] - }, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - - assert {:error, [:invalid_amount]} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - %{ - left: [%{account: revenue_account, amount: 100}], - right: [%{account: asset_account, amount: Decimal.new(200)}] - }, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - - assert {:error, [:invalid_amount]} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - %{ - left: [%{account: revenue_account, amount: Decimal.new(200)}], - right: [%{account: asset_account, amount: 200}] - }, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - - assert {:error, [:invalid_amount]} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - %{ - left: [%{account: revenue_account, amount: 100}], - right: [%{account: asset_account, amount: Decimal.new(200)}] - }, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - end + # 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"} - 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, - audit_details: audit_details - } do - assert {:error, :invalid_journal_entry} = - JournalEntry.create( - nil, - nil, - %{}, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - - assert {:error, :invalid_line_items} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - %{}, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - end + # {:ok, asset_account} = + # Account.create("10000", "cash", "asset", "description", audit_details) - test "update journal entry", %{ - transaction_date: transaction_date, - general_ledger_posting_date: general_ledger_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, - audit_details: audit_details - } do - assert {:ok, journal_entry} = - JournalEntry.create( - transaction_date, - general_ledger_posting_date, - t_accounts, - journal_entry_number, - transaction_reference_number, - "journal entry description", - journal_entry_details, - audit_details - ) - - assert {:error, :invalid_journal_entry} = JournalEntry.update(journal_entry, %{}) - - assert {:ok, updated_journal_entry} = - JournalEntry.update(journal_entry, %{ - journal_entry_description: "second updated description", - journal_entry_details: %{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.general_ledger_posting_date == - journal_entry.general_ledger_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 - - assert {:ok, updated_journal_entry} = - JournalEntry.update(journal_entry, %{ - journal_entry_description: "updated description", - posted: true - }) - - assert updated_journal_entry.general_ledger_posting_date == - journal_entry.general_ledger_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.posted == journal_entry.posted - - assert {:error, :already_posted_journal_entry} = - JournalEntry.update(updated_journal_entry, %{ - 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 + # {: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" + # } + # ] + # } + + # 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 + + # 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 + # ) + + # 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, [: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: 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 + # ) + + # 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 + # ) + + # 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 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.description == + # journal_entry.description + + # 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.journal_entry_number == journal_entry.journal_entry_number + + # 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, %{ + # 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 diff --git a/test/bookkeeping/core/line_item_test.exs b/test/bookkeeping/core/line_item_test.exs index c61a155..18dd537 100644 --- a/test/bookkeeping/core/line_item_test.exs +++ b/test/bookkeeping/core/line_item_test.exs @@ -3,105 +3,86 @@ 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: Money.new("USD", 100), + entry: :debit, + particulars: "line particulars" + } + + {: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), - line_item_description: "rent expense" - } - ], - right: [ - %{ - account: asset_account, - amount: Decimal.new(100), - line_item_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.line_item_description == "" + 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 + params = Map.put(params, :account, %{}) + assert {:error, :invalid_account} = LineItem.create(params) + 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.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.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.put(params, :particulars, nil) + assert {:error, :invalid_particulars} = 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 "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) + 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, :particulars, nil) + assert {:error, :invalid_line_item} = LineItem.validate(modified_line_item) + end end end diff --git a/test/bookkeeping/data/decode_error_chart_of_accounts.csv b/test/bookkeeping/data/decode_error_chart_of_accounts.csv deleted file mode 100644 index d055f04..0000000 --- a/test/bookkeeping/data/decode_error_chart_of_accounts.csv +++ /dev/null @@ -1,2 +0,0 @@ -Account Code,Account Name,Account Type,Account Description,Audit Details -10000010,Accounts Receivable Bulk Test,asset,Accounts Receivable, diff --git a/test/bookkeeping/data/duplicate_chart_of_accounts.csv b/test/bookkeeping/data/duplicate_chart_of_accounts.csv index ab18b11..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,Account Type,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 b8157e6..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,Account Type,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 new file mode 100644 index 0000000..3b5f8df --- /dev/null +++ b/test/bookkeeping/data/empty_chart_of_accounts_2.csv @@ -0,0 +1,4 @@ +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/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/invalid_chart_of_accounts.csv b/test/bookkeeping/data/invalid_chart_of_accounts.csv index cc19e73..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,Account Type,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 178eccc..23dd70b 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 +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,"{""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 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 0d74229..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,Account Type,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 26420e8..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,Account Type,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 new file mode 100644 index 0000000..3e3ca2c --- /dev/null +++ b/test/bookkeeping/data/valid_chart_of_accounts_2.csv @@ -0,0 +1,10 @@ +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""}" +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""}" 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 diff --git a/test/bookkeeping_test.exs b/test/bookkeeping_test.exs index eac53b0..d492a50 100644 --- a/test/bookkeeping_test.exs +++ b/test/bookkeeping_test.exs @@ -1,393 +1,325 @@ 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 - - test "all accounts" do - assert {:ok, _accounts} = Bookkeeping.all_accounts() - end - - 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") - 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", - %{} - ) + setup do + params = %{ + code: "1000", + name: "Cash", + classification: "asset", + description: "description", + audit_details: %{email: "example@example.com"}, + active: true + } - assert {:ok, _account} = - Bookkeeping.find_account_by_name("Cash_bookkeeping_test_for_find_by_name") + invalid_params = %{ + code: "1000", + name: "Cash", + classification: "invalid", + description: "description", + audit_details: %{email: "example@example.com"}, + active: true + } - assert {:error, :not_found} = - Bookkeeping.find_account_by_name("Cash_bookkeeping_test_for_find_by_name_not_found") + {:ok, params: params, invalid_params: invalid_params} end - test "search accounts" do - assert {:ok, _accounts} = Bookkeeping.search_accounts("Cash_bookkeeping_test") + ########################################################## + # 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 "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(%{}) + 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(%{active: true}) + + assert {:error, :invalid_params} = + Bookkeeping.create_account(%{audit_details: %{email: "example@example.com"}}) + end end - 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 + 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 "reset accounts" do - assert {:ok, _accounts} = Bookkeeping.reset_accounts() - end + assert length(accounts) == 9 - test "get chart of accounts state" do - assert {:ok, state} = Bookkeeping.get_chart_of_accounts_state() - assert is_map(state) - end + Process.sleep(300) - 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", - %{} - ) + assert %{accounts: [], errors: errors} = + Bookkeeping.import_accounts( + "../../../../test/bookkeeping/data/valid_chart_of_accounts.csv" + ) - {: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, + &(&1.reason in [:already_exists, :invalid_name, :invalid_classification]) ) + 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 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_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_audit_details} = + Bookkeeping.update_account(account, %{audit_details: nil}) + end + + 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 + 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