From 59f8bffacc978af513811e269b0a8ddff8f416ff Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:52:17 +0300 Subject: [PATCH 01/22] add credo and optional SQLite3 dependencies --- .credo.exs | 217 +++++++++++++++++++++++++++++++++++++++++++++++++++++ mix.exs | 8 +- 2 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 .credo.exs diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..baa1ea2 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,217 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/mix.exs b/mix.exs index 8e08671..5fda443 100644 --- a/mix.exs +++ b/mix.exs @@ -26,6 +26,7 @@ defmodule ExDoubleEntry.MixProject do defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(:test_mysql), do: ["lib", "test/support"] + defp elixirc_paths(:test_sqlite3), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] defp deps do @@ -33,10 +34,13 @@ defmodule ExDoubleEntry.MixProject do {:jason, "~> 1.2"}, {:money, "~> 1.9"}, {:ecto_sql, "~> 3.7"}, + {:ecto_sqlite3, ">= 0.0.0", optional: true}, + {:exqlite, ">= 0.0.0", optional: true}, {:postgrex, ">= 0.0.0", optional: true}, {:myxql, ">= 0.0.0", optional: true}, - {:ex_machina, "~> 2.7", only: [:test, :test_mysql]}, - {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} + {:ex_machina, "~> 2.7", only: [:test, :test_mysql, :test_sqlite3]}, + {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: :dev} ] end From f4175ba6a7546137e669f1971bce8f55585438d9 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:55:01 +0300 Subject: [PATCH 02/22] include migration scripts --- .formatter.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.formatter.exs b/.formatter.exs index d2cda26..5aa50b4 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,4 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test,priv}/**/*.{ex,exs}"] ] From a8cbd9650c1ce54c9fcd14d181f0223e1f4a99e1 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:55:13 +0300 Subject: [PATCH 03/22] ignore SQLite database files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e960495..7b14eac 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ ex_double_entry-*.tar /tmp/ mix.lock + +# SQLite files +*.db* From abf7d4ca42d222e7900f07cc39609f91a194bc0e Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:56:41 +0300 Subject: [PATCH 04/22] add test configuration for SQLite3 --- config/test_sqlite3.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 config/test_sqlite3.exs diff --git a/config/test_sqlite3.exs b/config/test_sqlite3.exs new file mode 100644 index 0000000..01bea99 --- /dev/null +++ b/config/test_sqlite3.exs @@ -0,0 +1,17 @@ +import Config + +config :ex_double_entry, + db: :sqlite3 + +config :ex_double_entry, ExDoubleEntry.Repo, + database: System.get_env("SQLITE_DB_PATH", "ex_double_entry_test.db"), + adapter: Ecto.Adapters.SQLite3, + journal_mode: :wal, + pool_size: 1, + pool: Ecto.Adapters.SQL.Sandbox, + show_sensitive_data_on_connection_error: true, + timeout: :infinity, + queue_target: 200, + queue_interval: 10 + +config :logger, level: :info From b7eacafbe47415d88f7fd9aaeadfd52106492afc Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:57:01 +0300 Subject: [PATCH 05/22] add commented-out configuration for SQLite --- config/dev.exs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config/dev.exs b/config/dev.exs index 3d7c8a3..4625110 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -6,3 +6,10 @@ config :ex_double_entry, ExDoubleEntry.Repo, database: "ex_double_entry_dev", hostname: "localhost", pool: Ecto.Adapters.SQL.Sandbox + +# When using SQLite +# config :ex_double_entry, ExDoubleEntry.Repo, +# database: Path.expand("../ex_double_entry_dev.db", __DIR__), +# pool_size: 5, +# stacktrace: true, +# show_sensitive_data_on_connection_error: true From b7b878d8d803574cb1bd5674ec053506b30571ae Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:58:20 +0300 Subject: [PATCH 06/22] replace `fetch_env/2` with `compile_env/2`, fix multi-line doctests, escape tuples vs. IAL --- lib/ex_double_entry.ex | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/ex_double_entry.ex b/lib/ex_double_entry.ex index 7c38048..a745a48 100644 --- a/lib/ex_double_entry.ex +++ b/lib/ex_double_entry.ex @@ -1,6 +1,6 @@ defmodule ExDoubleEntry do - @db_table_prefix Application.fetch_env!(:ex_double_entry, :db_table_prefix) - @repo Application.fetch_env!(:ex_double_entry, :repo) + @db_table_prefix Application.compile_env(:ex_double_entry, :db_table_prefix, nil) + @repo Application.compile_env(:ex_double_entry, :repo, ExDoubleEntry.Repo) def db_table_prefix, do: @db_table_prefix @@ -26,7 +26,7 @@ defmodule ExDoubleEntry do ## Examples iex> [ExDoubleEntry.make_account!(:savings)] |> ExDoubleEntry.lock_accounts(fn -> true end) - {:ok, true} + `{:ok, true}` """ defdelegate lock_accounts(accounts, fun), to: ExDoubleEntry.AccountBalance, as: :lock_multi! @@ -34,14 +34,11 @@ defmodule ExDoubleEntry do ## Examples iex> %ExDoubleEntry.Transfer{ - iex> money: Money.new(42, :USD), - iex> from: %ExDoubleEntry.Account{identifier: :checking, currency: :USD, balance: Money.new(42, :USD), positive_only?: false}, - iex> to: %ExDoubleEntry.Account{identifier: :savings, currency: :USD, balance: Money.new(0, :USD)}, - iex> code: :deposit - iex> } - iex> |> ExDoubleEntry.transfer!() - iex> |> Tuple.to_list() - iex> |> List.first() + ...> money: Money.new(42, :USD), + ...> from: %ExDoubleEntry.Account{identifier: :checking, currency: :USD, balance: Money.new(42, :USD), positive_only?: false}, + ...> to: %ExDoubleEntry.Account{identifier: :savings, currency: :USD, balance: Money.new(0, :USD)}, + ...> code: :deposit + ...> } |> ExDoubleEntry.transfer!() |> Tuple.to_list() |> List.first() :ok """ defdelegate transfer!(transfer), to: ExDoubleEntry.Transfer, as: :perform! From 61dab8850a4393f4fe28a6f538a7ce0456d6d392 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:58:36 +0300 Subject: [PATCH 07/22] switch single-clause `with` to `case` --- lib/ex_double_entry/application.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ex_double_entry/application.ex b/lib/ex_double_entry/application.ex index bf755cd..e0702e0 100644 --- a/lib/ex_double_entry/application.ex +++ b/lib/ex_double_entry/application.ex @@ -1,13 +1,13 @@ defmodule ExDoubleEntry.Application do + @moduledoc false use Application @impl true def start(_type, _args) do children = - with {:ok, repos} <- Application.fetch_env(:ex_double_entry, :ecto_repos) do + case Application.fetch_env(:ex_double_entry, :ecto_repos) do # ecto_repos are only required for development and test - repos - else + {:ok, repos} -> repos _ -> [] end From 97a00481af1a64b5c509f7a6e7cf422581fa8e11 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:58:50 +0300 Subject: [PATCH 08/22] add `:sqlite3` to `case` block --- lib/ex_double_entry/repo.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ex_double_entry/repo.ex b/lib/ex_double_entry/repo.ex index 656a413..cdeefe5 100644 --- a/lib/ex_double_entry/repo.ex +++ b/lib/ex_double_entry/repo.ex @@ -1,7 +1,9 @@ defmodule ExDoubleEntry.Repo do - @db Application.fetch_env!(:ex_double_entry, :db) + @moduledoc false + @db Application.compile_env(:ex_double_entry, :db, :postgres) @db_adapter (case @db do + :sqlite3 -> Ecto.Adapters.SQLite3 :postgres -> Ecto.Adapters.Postgres :mysql -> Ecto.Adapters.MyXQL end) From 3f243a146532ff4bdc9d35c30b3976d2eb0798d7 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 21:59:34 +0300 Subject: [PATCH 09/22] `@moduledoc false` (implementation details) --- lib/ex_double_entry/ecto_types/currency.ex | 1 + lib/ex_double_entry/ecto_types/identifier.ex | 1 + lib/ex_double_entry/ecto_types/scope.ex | 1 + 3 files changed, 3 insertions(+) diff --git a/lib/ex_double_entry/ecto_types/currency.ex b/lib/ex_double_entry/ecto_types/currency.ex index 7e5136c..c19d129 100644 --- a/lib/ex_double_entry/ecto_types/currency.ex +++ b/lib/ex_double_entry/ecto_types/currency.ex @@ -1,5 +1,6 @@ if Code.ensure_loaded?(Ecto.Type) do defmodule ExDoubleEntry.EctoType.Currency do + @moduledoc false if macro_exported?(Ecto.Type, :__using__, 1) do use Ecto.Type else diff --git a/lib/ex_double_entry/ecto_types/identifier.ex b/lib/ex_double_entry/ecto_types/identifier.ex index 3b432eb..64fffbb 100644 --- a/lib/ex_double_entry/ecto_types/identifier.ex +++ b/lib/ex_double_entry/ecto_types/identifier.ex @@ -1,5 +1,6 @@ if Code.ensure_loaded?(Ecto.Type) do defmodule ExDoubleEntry.EctoType.Identifier do + @moduledoc false if macro_exported?(Ecto.Type, :__using__, 1) do use Ecto.Type else diff --git a/lib/ex_double_entry/ecto_types/scope.ex b/lib/ex_double_entry/ecto_types/scope.ex index 7ecbdd0..9259749 100644 --- a/lib/ex_double_entry/ecto_types/scope.ex +++ b/lib/ex_double_entry/ecto_types/scope.ex @@ -1,5 +1,6 @@ if Code.ensure_loaded?(Ecto.Type) do defmodule ExDoubleEntry.EctoType.Scope do + @moduledoc false if macro_exported?(Ecto.Type, :__using__, 1) do use Ecto.Type else From ff5161fccb71d91205150e5a3251fc3e739c0329 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:00:12 +0300 Subject: [PATCH 10/22] add `@moduledoc` --- lib/ex_double_entry/schemas/line.ex | 36 ++++++++++++++++++++++ lib/ex_double_entry/services/account.ex | 36 ++++++++++++++++++++++ lib/ex_double_entry/services/transfer.ex | 38 ++++++++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/lib/ex_double_entry/schemas/line.ex b/lib/ex_double_entry/schemas/line.ex index 383e82a..c91214c 100644 --- a/lib/ex_double_entry/schemas/line.ex +++ b/lib/ex_double_entry/schemas/line.ex @@ -1,4 +1,40 @@ defmodule ExDoubleEntry.Line do + @moduledoc """ + Defines the Ecto schema and operations for transaction lines. + + ## Schema fields + + - `:account_identifier` - Identifier of the account for this line. + - `:account_scope` - Optional scope for the account. + - `:currency` - Currency code for the transaction. + - `:amount` - Transaction amount as an integer (in the smallest unit of the currency). + - `:balance_amount` - Updated balance after this transaction. + - `:code` - Transaction code. + - `:partner_identifier` - Identifier of the partner account. + - `:partner_scope` - Optional scope for the partner account. + - `:metadata` - Arbitrary map for additional transaction data. + - `:partner_line_id` - Foreign key to the paired (partner) line. + - `:account_balance_id` - Foreign key to the associated account balance. + - Timestamps with microsecond precision (`:utc_datetime_usec`). + + ## Associations + + - `belongs_to :partner_line` - The paired debit/credit line. + - `belongs_to :account_balance` - The affected account balance. + + ## Key functions + + - `insert!/1`: Inserts a new transaction line for a transfer, computing the updated balance. + - `update_partner_line_id!/2`: Updates the partner line ID for pairing debits and credits. + + ## Database considerations + + - Table name prefixed via `ExDoubleEntry.db_table_prefix()`. + - Validates required fields and foreign keys. + - Used internally by transfer operations to record atomic debits/credits. + + See `ExDoubleEntry.Transfer` for high-level transfer APIs and `ExDoubleEntry.AccountBalance` for balance management. + """ use Ecto.Schema import Ecto.Changeset diff --git a/lib/ex_double_entry/services/account.ex b/lib/ex_double_entry/services/account.ex index 8c39357..e4cffa8 100644 --- a/lib/ex_double_entry/services/account.ex +++ b/lib/ex_double_entry/services/account.ex @@ -1,4 +1,34 @@ defmodule ExDoubleEntry.Account do + @moduledoc """ + Defines the struct and operations for accounts. + + ## Struct fields + + - `:id` - Optional internal ID (from the database). + - `:identifier` - Required unique identifier for the account (atom or string). + - `:scope` - Optional scope to differentiate accounts (e.g., user-specific). + - `:currency` - Required currency code (e.g., `:USD`). + - `:balance` - Optional current balance as a `%Money{}` struct. + - `:positive_only?` - Flag indicating if the account balance must remain non-negative. + + ## Key functions + + - `present/1`: Converts an `%AccountBalance{}` schema or `nil` to an `%Account{}` struct. + - `lookup!/2`: Retrieves an existing `%Account{}` by identifier and options (e.g., currency, scope). Raises `ExDoubleEntry.Account.NotFoundError` if not found. + - `make!/2`: Creates a new `%Account{}` with zero balance, enforcing required fields and configuration. + + ## Configuration dependencies + + - Uses `:default_currency` from `:ex_double_entry` application config. + - Checks `:accounts` config for `:positive_only` flag per identifier. + + ## Exceptions + + - `ExDoubleEntry.Account.NotFoundError`: Raised when an account is not found. + - `ExDoubleEntry.Account.InvalidScopeError`: Raised for invalid scopes (e.g., empty string). + + See `ExDoubleEntry.AccountBalance` for balance-related operations and `ExDoubleEntry.Transfer` for transfers between accounts. + """ @enforce_keys [:identifier, :currency] defstruct [:id, :identifier, :scope, :currency, :balance, :positive_only?] @@ -52,9 +82,15 @@ defmodule ExDoubleEntry.Account do end defmodule ExDoubleEntry.Account.NotFoundError do + @moduledoc """ + Raised when an account is not found. + """ defexception message: "Account not found." end defmodule ExDoubleEntry.Account.InvalidScopeError do + @moduledoc """ + Raised for invalid scopes (empty string). + """ defexception message: "Invalid scope: empty string not allowed." end diff --git a/lib/ex_double_entry/services/transfer.ex b/lib/ex_double_entry/services/transfer.ex index 95cf21b..0e7f506 100644 --- a/lib/ex_double_entry/services/transfer.ex +++ b/lib/ex_double_entry/services/transfer.ex @@ -1,4 +1,42 @@ defmodule ExDoubleEntry.Transfer do + @moduledoc """ + Defines the struct and operations for performing atomic transfers. + + Transfers represent movements of money between accounts, enforcing double-entry principles (debit and credit pairs). Operations are atomic, using account locking and Ecto transactions to ensure consistency. + + ## Struct fields + + - `:money` - Required amount to transfer as a Money struct (positive only). + - `:from` - Required source Account struct. + - `:to` - Required destination Account struct. + - `:code` - Required transfer code (atom, validated against config). + - `:metadata` - Optional map for additional transaction details. + + ## Key functions + + - `perform!/1` and `perform!/2`: Validates and executes the transfer, raising on errors. Optional `:ensure_accounts` (default: `true`) creates accounts if missing. + - `perform/1` and `perform/2`: Non-raising variants, returning the `%Transfer{}` struct on success. + + ## Validation + + Uses `ExDoubleEntry.Guard` for checks: positive amount, valid code/pair, matching currencies, sufficient balance (if positive-only). + + ## Process + + 1. Validate transfer. + 2. Lock accounts. + 3. Insert paired debit/credit lines (`ExDoubleEntry.Line`). + 4. Update partner line IDs. + 5. Adjust balances. + 6. Commit transaction. + + ## Database considerations + + - Atomic via Ecto transactions and `AccountBalance.lock_multi!/2` on PostgreSQL and MySQL. + - For SQLite3, relies on WAL serialization instead of row-level locking. + + See `ExDoubleEntry.Guard` for validation details, `ExDoubleEntry.Line` for transaction records, and `ExDoubleEntry.AccountBalance` for locking and balances. + """ @enforce_keys [:money, :from, :to, :code] defstruct [:money, :from, :to, :code, :metadata] From c71e7f2b7b97d841e39c225f8f420cc1dae22382 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:00:47 +0300 Subject: [PATCH 11/22] switch to `compile_env/2`, add `@moduledoc`, disable locking for SQLite3 --- .../schemas/account_balance.ex | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/ex_double_entry/schemas/account_balance.ex b/lib/ex_double_entry/schemas/account_balance.ex index a728277..a231267 100644 --- a/lib/ex_double_entry/schemas/account_balance.ex +++ b/lib/ex_double_entry/schemas/account_balance.ex @@ -1,9 +1,41 @@ defmodule ExDoubleEntry.AccountBalance do + @moduledoc """ + Defines the Ecto schema and operations for account balances. + + ## Schema fields + + - `:identifier` - Unique identifier for the account. + - `:currency` - Currency code for the balance. + - `:scope` - Optional scope for the account. + - `:balance_amount` - The current balance as an integer (in the smallest unit of the currency). + - Timestamps with microsecond precision (`:utc_datetime_usec`). + + ## Key functions + + - `find/1`: Retrieves an `%AccountBalance{}` for a given `%Account{}` without locking. + - `create!/1`: Creates a new `%AccountBalance{}` with zero balance for an `%Account{}`. + - `for_account!/2`: Retrieves or creates an `%AccountBalance{}` for an `%Account{}`, with optional locking. + - `for_account/2`: Retrieves an `%AccountBalance{}` for an `%Account{}`, with optional locking. + - `lock!/1`: Locks and retrieves an `%AccountBalance{}` for an `%Account{}` (uses row-level locking for Postgres/MySQL; relies on transaction serialization in WAL mode for SQLite3). + - `lock_multi!/2`: Locks multiple accounts in a transaction and executes a function atomically (sorted to avoid deadlocks). + - `update_balance!/2`: Updates the balance of a locked `%AccountBalance{}`. + + ## Database considerations + + - Supports Postgres (default), MySQL, and SQLite3 via the `:db` compile-time configuration. + - For SQLite3 (`db: :sqlite3`), row-level locking is skipped, relying on WAL mode for serialization; use in environments of low concurrency / write contention only. + - Unique constraint on `[:scope, :currency, :identifier]` with adapter-specific naming (prefixed accordingly for SQLite3). + + See `ExDoubleEntry.Transfer` for transfer operations that use these balances. + """ + use Ecto.Schema import Ecto.{Changeset, Query} alias ExDoubleEntry.{Account, AccountBalance} + @db Application.compile_env(:ex_double_entry, :db, :postgres) + schema "#{ExDoubleEntry.db_table_prefix()}account_balances" do field(:identifier, ExDoubleEntry.EctoType.Identifier) field(:currency, ExDoubleEntry.EctoType.Currency) @@ -17,7 +49,18 @@ defmodule ExDoubleEntry.AccountBalance do %AccountBalance{} |> cast(params, [:identifier, :currency, :scope, :balance_amount]) |> validate_required([:identifier, :currency, :balance_amount]) - |> unique_constraint(:identifier, name: :scope_currency_identifier_index) + |> unique_constraint(:identifier, name: constraint_name()) + end + + @dialyzer {:nowarn_function, constraint_name: 0} + defp constraint_name do + base_name = "scope_currency_identifier_index" + + case @db do + :sqlite3 -> "#{ExDoubleEntry.db_table_prefix()}account_balances_#{base_name}" + db when db in [:postgres, :mysql] -> base_name + end + |> String.to_atom() end def find(%Account{} = account) do @@ -72,8 +115,8 @@ defmodule ExDoubleEntry.AccountBalance do defp lock_cond(query, lock) do case lock do - true -> lock(query, "FOR SHARE NOWAIT") - false -> query + true when @db != :sqlite3 -> lock(query, "FOR SHARE NOWAIT") + _ -> query end end @@ -83,7 +126,7 @@ defmodule ExDoubleEntry.AccountBalance do def lock_multi!(accounts, fun) do ExDoubleEntry.repo().transaction(fn -> - accounts |> Enum.sort() |> Enum.map(fn account -> lock!(account) end) + accounts |> Enum.sort() |> Enum.each(fn account -> lock!(account) end) fun.() end) end From 41df9c23620fe30b33b705a17e4958614080ed10 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:01:49 +0300 Subject: [PATCH 12/22] add `@moduledoc`, fix multi-line doctests, fix constraint name with prefix for SQLite, add `inspect/1` calls to messages --- lib/ex_double_entry/services/guard.ex | 157 ++++++++++++++------------ 1 file changed, 82 insertions(+), 75 deletions(-) diff --git a/lib/ex_double_entry/services/guard.ex b/lib/ex_double_entry/services/guard.ex index 97446fd..ba2ff83 100644 --- a/lib/ex_double_entry/services/guard.ex +++ b/lib/ex_double_entry/services/guard.ex @@ -1,16 +1,33 @@ defmodule ExDoubleEntry.Guard do + @moduledoc """ + Provides guard functions for validating `ExDoubleEntry.Transfer` structs before performing double-entry accounting operations. + + These guards ensure transfers meet criteria such as positive amounts, valid configurations, matching currencies, and sufficient balances for positive-only accounts. Each function returns `{:ok, transfer}` on success or `{:error, reason, message}` on failure. + + ## Key functions + + - `positive_amount?/1`: Ensures the transfer amount is positive. + - `valid_definition?/1`: Verifies the transfer code and account pair are defined in the application configuration (:transfers). + - `matching_currency?/1`: Checks that the currencies of the money, from-account, and to-account match. + - `positive_balance_if_enforced?/1`: Ensures the from-account has sufficient balance if it is marked as positive-only (configured via `:accounts`). + + ## Configuration dependencies + + - Relies on `:transfers` config for valid codes and pairs. + - Uses `:accounts` config for `:positive_only` flags. + + See `ExDoubleEntry.Transfer` for high-level transfer APIs. + """ alias ExDoubleEntry.Transfer @doc """ ## Examples - iex> %Transfer{money: Money.new(42, :USD), from: nil, to: nil, code: nil} - iex> |> Guard.positive_amount?() - {:ok, %Transfer{money: Money.new(42, :USD), from: nil, to: nil, code: nil}} + iex> %Transfer{money: Money.new(42, :USD), from: nil, to: nil, code: nil} |> Guard.positive_amount?() + `{:ok, %Transfer{money: Money.new(42, :USD), from: nil, to: nil, code: nil}}` - iex> %Transfer{money: Money.new(-42, :USD), from: nil, to: nil, code: nil} - iex> |> Guard.positive_amount?() - {:error, :positive_amount_only, ""} + iex> %Transfer{money: Money.new(-42, :USD), from: nil, to: nil, code: nil} |> Guard.positive_amount?() + `{:error, :positive_amount_only, ""}` """ def positive_amount?(%Transfer{money: money} = transfer) do case Money.positive?(money) do @@ -23,12 +40,11 @@ defmodule ExDoubleEntry.Guard do ## Examples iex> %Transfer{ - iex> money: nil, - iex> from: %Account{identifier: :checking, currency: :USD}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :deposit - iex> } - iex> |> Guard.valid_definition?() + ...> money: nil, + ...> from: %Account{identifier: :checking, currency: :USD}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :deposit + ...> } |> Guard.valid_definition?() { :ok, %Transfer{ @@ -40,22 +56,20 @@ defmodule ExDoubleEntry.Guard do } iex> %Transfer{ - iex> money: nil, - iex> from: %Account{identifier: :checking, currency: :USD}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :give_away - iex> } - iex> |> Guard.valid_definition?() - {:error, :undefined_transfer_code, "Transfer code :give_away is undefined."} + ...> money: nil, + ...> from: %Account{identifier: :checking, currency: :USD}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :give_away + ...> } |> Guard.valid_definition?() + `{:error, :undefined_transfer_code, "Transfer code :give_away is undefined."}` iex> %Transfer{ - iex> money: nil, - iex> from: %Account{identifier: :checking, currency: :USD}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :withdraw - iex> } - iex> |> Guard.valid_definition?() - {:error, :undefined_transfer_pair, "Transfer pair :checking -> :savings does not exist for code withdraw."} + ...> money: nil, + ...> from: %Account{identifier: :checking, currency: :USD}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :withdraw + ...> } |> Guard.valid_definition?() + `{:error, :undefined_transfer_pair, "Transfer pair :checking -> :savings does not exist for code :withdraw."}` """ def valid_definition?(%Transfer{from: from, to: to, code: code} = transfer) do with {:ok, pairs} <- @@ -66,11 +80,11 @@ defmodule ExDoubleEntry.Guard do {:ok, transfer} else :error -> - {:error, :undefined_transfer_code, "Transfer code :#{code} is undefined."} + {:error, :undefined_transfer_code, "Transfer code #{inspect(code)} is undefined."} false -> {:error, :undefined_transfer_pair, - "Transfer pair :#{from.identifier} -> :#{to.identifier} does not exist for code #{code}."} + "Transfer pair #{inspect(from.identifier)} -> #{inspect(to.identifier)} does not exist for code #{inspect(code)}."} end end @@ -78,12 +92,11 @@ defmodule ExDoubleEntry.Guard do ## Examples iex> %Transfer{ - iex> money: Money.new(42, :USD), - iex> from: %Account{identifier: :checking, currency: :USD}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :deposit - iex> } - iex> |> Guard.matching_currency?() + ...> money: Money.new(42, :USD), + ...> from: %Account{identifier: :checking, currency: :USD}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :deposit + ...> } |> Guard.matching_currency?() { :ok, %Transfer{ @@ -95,29 +108,27 @@ defmodule ExDoubleEntry.Guard do } iex> %Transfer{ - iex> money: Money.new(42, :AUD), - iex> from: %Account{identifier: :checking, currency: :USD}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :deposit - iex> } - iex> |> Guard.matching_currency?() - {:error, :mismatched_currencies, "Attempted to transfer :AUD from :checking in :USD to :savings in :USD."} + ...> money: Money.new(42, :AUD), + ...> from: %Account{identifier: :checking, currency: :USD}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :deposit + ...> } |> Guard.matching_currency?() + `{:error, :mismatched_currencies, "Attempted to transfer :AUD from :checking in :USD to :savings in :USD."}` iex> %Transfer{ - iex> money: Money.new(42, :USD), - iex> from: %Account{identifier: :checking, currency: :USD}, - iex> to: %Account{identifier: :savings, currency: :AUD}, - iex> code: :deposit - iex> } - iex> |> Guard.matching_currency?() - {:error, :mismatched_currencies, "Attempted to transfer :USD from :checking in :USD to :savings in :AUD."} + ...> money: Money.new(42, :USD), + ...> from: %Account{identifier: :checking, currency: :USD}, + ...> to: %Account{identifier: :savings, currency: :AUD}, + ...> code: :deposit + ...> } |> Guard.matching_currency?() + `{:error, :mismatched_currencies, "Attempted to transfer :USD from :checking in :USD to :savings in :AUD."}` """ def matching_currency?(%Transfer{money: money, from: from, to: to} = transfer) do if from.currency == money.currency and to.currency == money.currency do {:ok, transfer} else {:error, :mismatched_currencies, - "Attempted to transfer :#{money.currency} from :#{from.identifier} in :#{from.currency} to :#{to.identifier} in :#{to.currency}."} + "Attempted to transfer #{inspect(money.currency)} from #{inspect(from.identifier)} in #{inspect(from.currency)} to #{inspect(to.identifier)} in #{inspect(to.currency)}."} end end @@ -125,12 +136,11 @@ defmodule ExDoubleEntry.Guard do ## Examples iex> %Transfer{ - iex> money: Money.new(42, :USD), - iex> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(42, :USD), positive_only?: true}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :deposit - iex> } - iex> |> Guard.positive_balance_if_enforced?() + ...> money: Money.new(42, :USD), + ...> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(42, :USD), positive_only?: true}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :deposit + ...> } |> Guard.positive_balance_if_enforced?() { :ok, %Transfer{ @@ -142,12 +152,11 @@ defmodule ExDoubleEntry.Guard do } iex> %Transfer{ - iex> money: Money.new(42, :USD), - iex> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(10, :USD), positive_only?: false}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :deposit - iex> } - iex> |> Guard.positive_balance_if_enforced?() + ...> money: Money.new(42, :USD), + ...> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(10, :USD), positive_only?: false}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :deposit + ...> } |> Guard.positive_balance_if_enforced?() { :ok, %Transfer{ @@ -159,12 +168,11 @@ defmodule ExDoubleEntry.Guard do } iex> %Transfer{ - iex> money: Money.new(42, :USD), - iex> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(10, :USD)}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :deposit - iex> } - iex> |> Guard.positive_balance_if_enforced?() + ...> money: Money.new(42, :USD), + ...> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(10, :USD)}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :deposit + ...> } |> Guard.positive_balance_if_enforced?() { :ok, %Transfer{ @@ -176,18 +184,17 @@ defmodule ExDoubleEntry.Guard do } iex> %Transfer{ - iex> money: Money.new(42, :USD), - iex> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(10, :USD), positive_only?: true}, - iex> to: %Account{identifier: :savings, currency: :USD}, - iex> code: :deposit - iex> } - iex> |> Guard.positive_balance_if_enforced?() - {:error, :insufficient_balance, "Transfer amount: 42, :checking balance amount: 10"} + ...> money: Money.new(42, :USD), + ...> from: %Account{identifier: :checking, currency: :USD, balance: Money.new(10, :USD), positive_only?: true}, + ...> to: %Account{identifier: :savings, currency: :USD}, + ...> code: :deposit + ...> } |> Guard.positive_balance_if_enforced?() + `{:error, :insufficient_balance, "Transfer amount: 42, :checking balance amount: 10"}` """ def positive_balance_if_enforced?(%Transfer{money: money, from: from} = transfer) do if !!from.positive_only? and Money.cmp(from.balance, money) == :lt do {:error, :insufficient_balance, - "Transfer amount: #{money.amount}, :#{from.identifier} balance amount: #{from.balance.amount}"} + "Transfer amount: #{money.amount}, #{inspect(from.identifier)} balance amount: #{from.balance.amount}"} else {:ok, transfer} end From 08a02cdeb857c330e177c1ae455721a4c3d5b217 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:02:33 +0300 Subject: [PATCH 13/22] determine JSON type with `get_env/2` of `:db`; `mix format` --- .../migrations/001_ex_double_entry_tables.exs | 75 ++++++++++++------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/priv/repo/migrations/001_ex_double_entry_tables.exs b/priv/repo/migrations/001_ex_double_entry_tables.exs index d53ca9b..597aec1 100644 --- a/priv/repo/migrations/001_ex_double_entry_tables.exs +++ b/priv/repo/migrations/001_ex_double_entry_tables.exs @@ -3,40 +3,61 @@ defmodule ExDoubleEntry.Repo.Migrations.ExDoubleEntryMoney do def change do json_type = - if ExDoubleEntry.Repo.__adapter__ == Ecto.Adapters.Postgres do - :jsonb - else - :json - end - - create table(:"#{ExDoubleEntry.db_table_prefix}account_balances") do - add :identifier, :string, null: false - add :currency, :string, null: false - add :scope, :string, null: false, default: "" - add :balance_amount, :bigint, null: false + if Application.get_env(:ex_double_entry, :db) == :postgres, + do: :jsonb, + else: :json + + create table(:"#{ExDoubleEntry.db_table_prefix()}account_balances") do + add(:identifier, :string, null: false) + add(:currency, :string, null: false) + add(:scope, :string, null: false, default: "") + add(:balance_amount, :bigint, null: false) timestamps(type: :utc_datetime_usec) end - create index(:"#{ExDoubleEntry.db_table_prefix}account_balances", [:scope, :currency, :identifier], unique: true, name: :scope_currency_identifier_index) - - create table(:"#{ExDoubleEntry.db_table_prefix}lines") do - add :account_identifier, :string, null: false - add :account_scope, :string, null: false, default: "" - add :currency, :string, null: false - add :amount, :bigint, null: false - add :balance_amount, :bigint, null: false - add :code, :string, null: false - add :partner_identifier, :string, null: false - add :partner_scope, :string, null: false, default: "" - add :metadata, json_type - add :partner_line_id, references(:"#{ExDoubleEntry.db_table_prefix}lines") - add :account_balance_id, references(:"#{ExDoubleEntry.db_table_prefix}account_balances"), null: false + create( + index( + :"#{ExDoubleEntry.db_table_prefix()}account_balances", + [:scope, :currency, :identifier], + unique: true, + name: :scope_currency_identifier_index + ) + ) + + create table(:"#{ExDoubleEntry.db_table_prefix()}lines") do + add(:account_identifier, :string, null: false) + add(:account_scope, :string, null: false, default: "") + add(:currency, :string, null: false) + add(:amount, :bigint, null: false) + add(:balance_amount, :bigint, null: false) + add(:code, :string, null: false) + add(:partner_identifier, :string, null: false) + add(:partner_scope, :string, null: false, default: "") + add(:metadata, json_type) + add(:partner_line_id, references(:"#{ExDoubleEntry.db_table_prefix()}lines")) + + add(:account_balance_id, references(:"#{ExDoubleEntry.db_table_prefix()}account_balances"), + null: false + ) timestamps(type: :utc_datetime_usec) end - create index(:"#{ExDoubleEntry.db_table_prefix}lines", [:code, :account_identifier, :currency, :inserted_at], name: :code_account_identifier_currency_inserted_at_index) - create index(:"#{ExDoubleEntry.db_table_prefix}lines", [:account_scope, :account_identifier, :currency, :inserted_at], name: :account_scope_account_identifier_currency_inserted_at_index) + create( + index( + :"#{ExDoubleEntry.db_table_prefix()}lines", + [:code, :account_identifier, :currency, :inserted_at], + name: :code_account_identifier_currency_inserted_at_index + ) + ) + + create( + index( + :"#{ExDoubleEntry.db_table_prefix()}lines", + [:account_scope, :account_identifier, :currency, :inserted_at], + name: :account_scope_account_identifier_currency_inserted_at_index + ) + ) end end From 45957228260811cf4dd10fcc7e245c483a1f5376 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:03:04 +0300 Subject: [PATCH 14/22] tag tests that require locking to exlude them from SQLite-based tests --- test/ex_double_entry/schemas/account_balance_test.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/ex_double_entry/schemas/account_balance_test.exs b/test/ex_double_entry/schemas/account_balance_test.exs index bc9a7a0..87fddd9 100644 --- a/test/ex_double_entry/schemas/account_balance_test.exs +++ b/test/ex_double_entry/schemas/account_balance_test.exs @@ -116,6 +116,7 @@ defmodule ExDoubleEntry.AccountBalanceTest do [acc_a: acc_a, acc_b: acc_b] end + @tag :requires_locking test "multiple locks", %{acc_a: acc_a, acc_b: acc_b} do tasks = for i <- 0..4 do @@ -128,6 +129,7 @@ defmodule ExDoubleEntry.AccountBalanceTest do assert Enum.reduce(tasks, 0, fn {:ok, n}, acc -> acc + n end) == 10 end + @tag :requires_locking test "failed locks", %{acc_a: acc_a, acc_b: acc_b} do [ Task.async(fn -> From f3125228fac9ab87c3c0a18c96e8ac0f5a79d6a7 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:03:21 +0300 Subject: [PATCH 15/22] exclude tests that require locking when using SQLite --- test/test_helper.exs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/test_helper.exs b/test/test_helper.exs index a5f6683..46f8ce8 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,10 @@ {:ok, _} = Application.ensure_all_started(:ex_machina) ExUnit.start(timeout: 300_000) + +if Application.get_env(:ex_double_entry, :db) == :sqlite3 do + ExUnit.configure(exclude: [:requires_locking]) +end + Ecto.Adapters.SQL.Sandbox.mode(ExDoubleEntry.repo(), :manual) require Logger From 2b0bab324eab38de361cb34d1183db4c982cdaa4 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:07:32 +0300 Subject: [PATCH 16/22] add `@moduledoc` --- lib/ex_double_entry.ex | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/ex_double_entry.ex b/lib/ex_double_entry.ex index a745a48..a88b419 100644 --- a/lib/ex_double_entry.ex +++ b/lib/ex_double_entry.ex @@ -1,4 +1,15 @@ defmodule ExDoubleEntry do + @moduledoc """ + Contains the main public API, delegating to children modules. + + # Key functions (delegated) + + - `make_account!/2`: Creates a new account with zero balance. + - `lookup_account!/2`: Retrieves an existing account. + - `lock_accounts/2`: Locks multiple accounts and executes a function atomically (only on PostgreSQL and MySQL). + - `transfer!/1`: Performs a validated, atomic transfer (raising on errors). + - `transfer/1`: Non-raising variant of `transfer!/1`. + """ @db_table_prefix Application.compile_env(:ex_double_entry, :db_table_prefix, nil) @repo Application.compile_env(:ex_double_entry, :repo, ExDoubleEntry.Repo) From 966cbaf6f8da1e164b1564a2fb424bf68854add2 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:18:57 +0300 Subject: [PATCH 17/22] update `README.md` --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index df3ae98..20306ca 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An Elixir double-entry library inspired by Ruby's [DoubleEntry](https://github.c - Postgres 9.4+ (for `JSONB` support) - MySQL 8.0+ (for row locking support) +- SQLite3 (database locking, WAL mode) ## Installation @@ -20,6 +21,9 @@ def deps do # pick one DB package {:postgrex, ">= 0.0.0"}, {:myxql, ">= 0.0.0"}, + {:exqlite, ">= 0.0.0"}, + # for SQLite3 also add Ecto SQLite3: + {:ecto_sqlite3, ">= 0.0.0"} ] end ``` @@ -71,10 +75,8 @@ config :ex_double_entry, ```elixir # creates a new account with 0 balance ExDoubleEntry.make_account!( - # identifier of the account, in atom - :savings, - # currency can be any arbitrary atom - currency: :USD, + :savings, # identifier of the account, in atom + currency: :USD, # currency can be any arbitrary atom # optional, scope can be any arbitrary string # # due to DB index on `NULL` values, scope value can only be `nil` (stored as @@ -159,6 +161,10 @@ ExDoubleEntry.lock_accounts([account_a, account_b], fn -> end) ``` +### Locking and SQLite3 + +SQLite3 is supported but **does not offer row-level locking**. Instead, it uses **database-level locking**, serializing all write operations. When using SQLite3 (configured with `db: :sqlite3`), `lock_accounts/2` and `transfer!/1` rely on Write-Ahead Logging (WAL) mode to ensure atomicity through transaction serialization. This is sufficient for low-concurrency environments (e.g., development, testing, or single-user applications). For high-concurrency production systems, prefer Postgres (9.4+) or MySQL (8.0+). + ## License Licensed under [MIT](LICENSE.md). From a98a138e72d2abe7337cc146dc97bbe1477d1f3b Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:34:47 +0300 Subject: [PATCH 18/22] disable Credo warning for large numbers and underscores --- .credo.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.credo.exs b/.credo.exs index baa1ea2..a4eb86a 100644 --- a/.credo.exs +++ b/.credo.exs @@ -96,7 +96,7 @@ # {Credo.Check.Readability.AliasOrder, []}, {Credo.Check.Readability.FunctionNames, []}, - {Credo.Check.Readability.LargeNumbers, []}, + # {Credo.Check.Readability.LargeNumbers, []}, {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, {Credo.Check.Readability.ModuleAttributeNames, []}, {Credo.Check.Readability.ModuleDoc, []}, From 7a6ca4e7aeb306dd27bc9f97946acc80d72f6589 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Mon, 15 Sep 2025 22:34:56 +0300 Subject: [PATCH 19/22] `@moduledoc false` --- test/support/data_case.ex | 1 + test/support/factory.ex | 1 + 2 files changed, 2 insertions(+) diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 3da553a..59ef1b9 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -1,4 +1,5 @@ defmodule ExDoubleEntry.DataCase do + @moduledoc false use ExUnit.CaseTemplate using do diff --git a/test/support/factory.ex b/test/support/factory.ex index e24dd20..948e50f 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -1,4 +1,5 @@ defmodule ExDoubleEntry.Factory do + @moduledoc false use ExMachina.Ecto, repo: ExDoubleEntry.repo() def account_balance_factory do From a309f4754c10de68793c891709c3eee96b731709 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Sun, 18 Jan 2026 15:59:59 +0200 Subject: [PATCH 20/22] Replace compile-time attributes with runtime functions --- lib/ex_double_entry.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/ex_double_entry.ex b/lib/ex_double_entry.ex index a88b419..d6b3c45 100644 --- a/lib/ex_double_entry.ex +++ b/lib/ex_double_entry.ex @@ -10,12 +10,10 @@ defmodule ExDoubleEntry do - `transfer!/1`: Performs a validated, atomic transfer (raising on errors). - `transfer/1`: Non-raising variant of `transfer!/1`. """ - @db_table_prefix Application.compile_env(:ex_double_entry, :db_table_prefix, nil) - @repo Application.compile_env(:ex_double_entry, :repo, ExDoubleEntry.Repo) - def db_table_prefix, do: @db_table_prefix + def db_table_prefix, do: Application.get_env(:ex_double_entry, :db_table_prefix, nil) - def repo, do: @repo + def repo, do: Application.fetch_env!(:ex_double_entry, :repo) @doc """ ## Examples From dd36cef271a372a732d9c2568d839ebcd006c386 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Sun, 18 Jan 2026 16:05:39 +0200 Subject: [PATCH 21/22] Update account_balance.ex --- lib/ex_double_entry/schemas/account_balance.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ex_double_entry/schemas/account_balance.ex b/lib/ex_double_entry/schemas/account_balance.ex index a231267..b59af83 100644 --- a/lib/ex_double_entry/schemas/account_balance.ex +++ b/lib/ex_double_entry/schemas/account_balance.ex @@ -36,6 +36,8 @@ defmodule ExDoubleEntry.AccountBalance do @db Application.compile_env(:ex_double_entry, :db, :postgres) + defp repo(), do: ExDoubleEntry.repo() + schema "#{ExDoubleEntry.db_table_prefix()}account_balances" do field(:identifier, ExDoubleEntry.EctoType.Identifier) field(:currency, ExDoubleEntry.EctoType.Currency) @@ -75,7 +77,7 @@ defmodule ExDoubleEntry.AccountBalance do balance_amount: 0 } |> changeset() - |> ExDoubleEntry.repo().insert!() + |> repo().insert!() end def for_account!(%Account{} = account) do @@ -103,7 +105,7 @@ defmodule ExDoubleEntry.AccountBalance do ) |> scope_cond(scope) |> lock_cond(lock) - |> ExDoubleEntry.repo().one() + |> repo().one() end defp scope_cond(query, scope) do @@ -125,7 +127,7 @@ defmodule ExDoubleEntry.AccountBalance do end def lock_multi!(accounts, fun) do - ExDoubleEntry.repo().transaction(fn -> + repo().transaction(fn -> accounts |> Enum.sort() |> Enum.each(fn account -> lock!(account) end) fun.() end) @@ -135,6 +137,6 @@ defmodule ExDoubleEntry.AccountBalance do account |> lock!() |> Ecto.Changeset.change(balance_amount: balance_amount) - |> ExDoubleEntry.repo().update!() + |> repo().update!() end end From a4b6bb5f055602b151e203fe4cd49313e8a73c44 Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou Date: Sun, 18 Jan 2026 16:06:37 +0200 Subject: [PATCH 22/22] Update line.ex --- lib/ex_double_entry/schemas/line.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/ex_double_entry/schemas/line.ex b/lib/ex_double_entry/schemas/line.ex index c91214c..df37a3e 100644 --- a/lib/ex_double_entry/schemas/line.ex +++ b/lib/ex_double_entry/schemas/line.ex @@ -40,6 +40,8 @@ defmodule ExDoubleEntry.Line do alias ExDoubleEntry.{AccountBalance, Line} + defp repo(), do: ExDoubleEntry.repo() + schema "#{ExDoubleEntry.db_table_prefix()}lines" do field(:account_identifier, ExDoubleEntry.EctoType.Identifier) field(:account_scope, ExDoubleEntry.EctoType.Scope) @@ -98,12 +100,12 @@ defmodule ExDoubleEntry.Line do account_balance_id: account.id } |> changeset() - |> ExDoubleEntry.repo().insert!() + |> repo().insert!() end def update_partner_line_id!(%Line{} = line, partner_line_id) do line |> Ecto.Changeset.change(partner_line_id: partner_line_id) - |> ExDoubleEntry.repo().update!() + |> repo().update!() end end