├── .tool-versions ├── test ├── test_helper.exs └── bank_test.exs ├── lib ├── bank │ ├── transfer.ex │ ├── repo.ex │ ├── commands.ex │ ├── events.ex │ ├── application.ex │ ├── schemas │ │ └── account.ex │ ├── router.ex │ ├── account.ex │ ├── transfer_process.ex │ ├── supervisor.ex │ └── account_projector.ex └── bank.ex ├── .formatter.exs ├── priv └── repo │ └── migrations │ ├── 20180203064248_create_accounts.exs │ └── 20180128184736_create_projection_versions.exs ├── README.md ├── .gitignore ├── config └── config.exs ├── mix.exs └── mix.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.6.0 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start([skip: :skip]) 2 | -------------------------------------------------------------------------------- /lib/bank/transfer.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Transfer do 2 | end 3 | -------------------------------------------------------------------------------- /lib/bank/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Repo do 2 | use Ecto.Repo, otp_app: :bank 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/bank/commands.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Commands do 2 | defmodule OpenAccount, do: defstruct [:account_id] 3 | end 4 | -------------------------------------------------------------------------------- /lib/bank/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Events do 2 | defmodule AccountOpened, do: defstruct [:account_id] 3 | end 4 | -------------------------------------------------------------------------------- /lib/bank/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, args) do 7 | Bank.Supervisor.start_link(args) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/bank/schemas/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Schemas.Account do 2 | use Ecto.Schema 3 | 4 | @primary_key {:account_id, :binary_id, autogenerate: false} 5 | 6 | schema "accounts" do 7 | field(:balance, :integer) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/bank/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Router do 2 | use Commanded.Commands.Router 3 | 4 | alias Bank.Account 5 | alias Bank.Commands, as: C 6 | 7 | dispatch( 8 | [ 9 | C.OpenAccount 10 | ], 11 | to: Account, 12 | identity: :account_id 13 | ) 14 | end 15 | -------------------------------------------------------------------------------- /lib/bank/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Account do 2 | alias __MODULE__ 3 | alias Bank.Commands, as: C 4 | alias Bank.Events, as: E 5 | 6 | defstruct [] 7 | 8 | def execute(_, %C.OpenAccount{} = cmd) do 9 | %E.AccountOpened{account_id: cmd.account_id} 10 | end 11 | 12 | def apply(s, _), do: s 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180203064248_create_accounts.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.Repo.Migrations.CreateAccounts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:accounts, primary_key: false) do 6 | add(:account_id, :uuid, primary_key: true) 7 | add(:balance, :integer) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/bank/transfer_process.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.TransferProcess do 2 | use Commanded.ProcessManagers.ProcessManager, 3 | name: "TransferProcess", 4 | router: Bank.Router 5 | 6 | alias Bank.Transfer 7 | alias Bank.Commands, as: C 8 | alias Bank.Events, as: E 9 | 10 | defstruct [] 11 | 12 | def handle(state, event) 13 | 14 | def apply(state, event) 15 | end 16 | -------------------------------------------------------------------------------- /lib/bank/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | def start_link(arg) do 7 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 8 | end 9 | 10 | def init(_arg) do 11 | children = [ 12 | {Bank.Repo, []}, 13 | ] 14 | 15 | Supervisor.init(children, strategy: :one_for_one) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180128184736_create_projection_versions.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateProjectionVersions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:projection_versions, primary_key: false) do 6 | add :projection_name, :text, primary_key: true 7 | add :last_seen_event_number, :bigint 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/bank/account_projector.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.AccountProjector do 2 | use Commanded.Projections.Ecto, 3 | name: "AccountProjector" 4 | 5 | alias Bank.Events, as: E 6 | alias Bank.Schemas, as: S 7 | 8 | """ 9 | defp increase_balance(multi, account_id, amount) do 10 | Ecto.Multi.insert( 11 | multi, 12 | :increase_balance, 13 | %S.Account{account_id: account_id, balance: amount}, 14 | conflict_target: :account_id, 15 | on_conflict: [inc: [balance: amount]] 16 | ) 17 | end 18 | """ 19 | end 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bank 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `bank` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:bank, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at [https://hexdocs.pm/bank](https://hexdocs.pm/bank). 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | bank-*.tar 24 | 25 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: String.to_atom((System.get_env "LOGLVL") || "error") 4 | 5 | 6 | config :bank, 7 | ecto_repos: [Bank.Repo] 8 | 9 | config :bank, Bank.Repo, 10 | adapter: Ecto.Adapters.Postgres, 11 | username: "postgres", 12 | password: "postgres", 13 | database: "bank_readstore_dev", 14 | hostname: "localhost", 15 | port: 5432, 16 | pool_size: 10 17 | 18 | config :commanded, 19 | event_store_adapter: Commanded.EventStore.Adapters.EventStore 20 | 21 | # Configure the event store database 22 | config :eventstore, EventStore.Storage, 23 | serializer: EventStore.TermSerializer, 24 | username: "postgres", 25 | password: "postgres", 26 | database: "bank_eventstore_dev", 27 | hostname: "localhost", 28 | port: 5432, 29 | pool_size: 10 30 | 31 | config :commanded_ecto_projections, 32 | repo: Bank.Repo 33 | -------------------------------------------------------------------------------- /lib/bank.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank do 2 | @moduledoc """ 3 | Documentation for Bank. 4 | """ 5 | 6 | alias Bank.Transfer 7 | alias Bank.Repo 8 | alias Bank.Router 9 | alias Bank.Commands, as: C 10 | alias Bank.Schemas, as: S 11 | 12 | def open_account do 13 | id = UUID.uuid4 14 | %C.OpenAccount{account_id: id} 15 | |> Router.dispatch 16 | |> case do 17 | :ok -> {:ok, id} 18 | err -> err 19 | end 20 | end 21 | 22 | def add_funds(_account_id, _amount) do 23 | {:error, :not_implemented} 24 | end 25 | 26 | def remove_funds(_account_id, _amount) do 27 | {:error, :not_implemented} 28 | end 29 | 30 | def transfer(_source_id, _target_id, _amount) do 31 | {:error, :not_implemented} 32 | end 33 | 34 | def get_balance(_account_id) do 35 | {:error, :not_implemented} 36 | end 37 | 38 | def get_statement(_account_id) do 39 | {:error, :not_implemented} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :bank, 7 | version: "0.1.0", 8 | elixir: "~> 1.6", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | aliases: aliases() 12 | ] 13 | end 14 | 15 | # Run "mix help compile.app" to learn about applications. 16 | def application do 17 | [ 18 | mod: {Bank.Application, []}, 19 | extra_applications: [:logger, :eventstore] 20 | ] 21 | end 22 | 23 | # Run "mix help deps" to learn about dependencies. 24 | defp deps do 25 | [ 26 | # Domain 27 | {:commanded, github: "commanded/commanded", override: true}, 28 | {:commanded_eventstore_adapter, "~> 0.3"}, 29 | {:commanded_ecto_projections, "~> 0.6"}, 30 | {:ecto, "~> 2.1"}, 31 | {:postgrex, ">= 0.0.0"}, 32 | {:uuid, "~> 1.1"}, 33 | {:eventstore, ">= 0.13.0"}, 34 | ] 35 | end 36 | 37 | defp aliases do 38 | [ 39 | setup_es: ["event_store.create", "event_store.init"], 40 | setup_ecto: ["ecto.create", "ecto.migrate"], 41 | setup_db: ["setup_es", "setup_ecto"], 42 | 43 | drop_db: ["ecto.drop", "event_store.drop"], 44 | reset_db: ["drop_db", "setup_db"], 45 | 46 | test: ["reset_db", "test --trace"], 47 | ] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "commanded": {:git, "https://github.com/commanded/commanded.git", "720001ef696b89b16536d26354024991494bc2aa", []}, 3 | "commanded_ecto_projections": {:hex, :commanded_ecto_projections, "0.6.0", "004b7ed525ab8a04a71bf3dfb3baf070cdfaae6ab945b3d002ceae0fb978dd4d", [:mix], [{:commanded, ">= 0.12.0", [hex: :commanded, repo: "hexpm", optional: false]}, {:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "commanded_eventstore_adapter": {:hex, :commanded_eventstore_adapter, "0.3.0", "bd8ea1ae6d62edc74a97047ff05f46b9d460246261b2d890612dcfed2280b1f8", [:mix], [{:commanded, ">= 0.15.0", [hex: :commanded, repo: "hexpm", optional: false]}, {:eventstore, ">= 0.13.0", [hex: :eventstore, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 6 | "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [:mix], [], "hexpm"}, 8 | "ecto": {:hex, :ecto, "2.2.8", "a4463c0928b970f2cee722cd29aaac154e866a15882c5737e0038bbfcf03ec2c", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "eventstore": {:hex, :eventstore, "0.13.2", "45e5492d1965a4ec6744eb29d29f2871be27cd8fe32de8747dd85d8c8e311629", [:mix], [{:fsm, "~> 0.3", [hex: :fsm, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: false]}, {:swarm, "~> 3.0", [hex: :swarm, repo: "hexpm", optional: true]}, {:uuid, "~> 1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "fsm": {:hex, :fsm, "0.3.0", "d00e0a3c68f8cf8feb24ce3a732164638ec652c48ce416b66d4e375b6ee415eb", [:mix], [], "hexpm"}, 11 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 12 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 13 | "postgrex": {:hex, :postgrex, "0.13.4", "f58e319c5451bfda86ba6a45ce6dca311193d0a9861323d0d16e8d02e25adc41", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"}, 15 | } 16 | -------------------------------------------------------------------------------- /test/bank_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankTest do 2 | use ExUnit.Case 3 | doctest Bank 4 | 5 | describe "opening accounts" do 6 | setup do 7 | [r: Bank.open_account()] 8 | end 9 | 10 | test "it works", %{r: r} do 11 | assert {:ok, _} = r 12 | end 13 | end 14 | 15 | describe "basic balance" do 16 | @describetag :skip 17 | setup do 18 | [r: Bank.open_account()] 19 | end 20 | 21 | test "accounts start with 0 balance", %{r: r} do 22 | {:ok, id} = r 23 | wait_until(fn -> 24 | assert {:ok, 0} = Bank.get_balance(id) 25 | end) 26 | end 27 | 28 | test "we can't get the balance for unexistent accounts" do 29 | assert {:error, :not_found} = Bank.get_balance(UUID.uuid4) 30 | end 31 | end 32 | 33 | describe "adding funds" do 34 | @describetag :skip 35 | setup do 36 | {:ok, account_id} = Bank.open_account() 37 | [id: account_id, r: Bank.add_funds(account_id, 100)] 38 | end 39 | 40 | test "it works", %{r: r} do 41 | assert :ok = r 42 | end 43 | 44 | test "we cant add funds to inexistent accounts" do 45 | assert {:error, :not_found} = Bank.add_funds(UUID.uuid4, 100) 46 | end 47 | 48 | test "it increases the balance", %{id: id} do 49 | wait_until(fn -> 50 | assert {:ok, 100} = Bank.get_balance(id) 51 | end) 52 | end 53 | end 54 | 55 | describe "removing funds" do 56 | @describetag :skip 57 | setup do 58 | {:ok, account_id} = Bank.open_account() 59 | :ok = Bank.add_funds(account_id, 100) 60 | [id: account_id, r: Bank.remove_funds(account_id, 10)] 61 | end 62 | 63 | test "it works", %{r: r} do 64 | assert :ok = r 65 | end 66 | 67 | test "it decreases the balance", %{id: id} do 68 | wait_until(fn -> 69 | assert {:ok, 90} = Bank.get_balance(id) 70 | end) 71 | end 72 | 73 | test "it decreases the balance on the write side" do 74 | {:ok, account_id} = Bank.open_account() 75 | :ok = Bank.add_funds(account_id, 100) 76 | :ok = Bank.remove_funds(account_id, 100) 77 | response = Bank.remove_funds(account_id, 100) 78 | assert {:error, :insufficient_funds} = response 79 | end 80 | 81 | test "we cant remove funds to inexistent accounts" do 82 | assert {:error, :not_found} = Bank.remove_funds(UUID.uuid4, 100) 83 | end 84 | 85 | test "we cant remove funds without money", %{id: id} do 86 | resp = Bank.remove_funds(id, 10000) 87 | assert {:error, :insufficient_funds} = resp 88 | end 89 | end 90 | 91 | describe "transfering money" do 92 | @describetag :skip 93 | setup do 94 | {:ok, source_id} = Bank.open_account() 95 | {:ok, target_id} = Bank.open_account() 96 | :ok = Bank.add_funds(source_id, 100) 97 | r = Bank.transfer(source_id, target_id, 10) 98 | [source_id: source_id, target_id: target_id, r: r] 99 | end 100 | 101 | test "it works", %{r: r} do 102 | assert :ok = r 103 | end 104 | 105 | test "it decreases the source balance", %{source_id: id} do 106 | wait_until(fn -> 107 | assert {:ok, 90} = Bank.get_balance(id) 108 | end) 109 | end 110 | 111 | test "it increases the target balance", %{target_id: id} do 112 | wait_until(fn -> 113 | assert {:ok, 10} = Bank.get_balance(id) 114 | end) 115 | end 116 | 117 | test "we cant transfer without money", ctx do 118 | resp = Bank.transfer(ctx[:source_id], ctx[:target_id], 10000) 119 | assert {:error, :insufficient_funds} = resp 120 | end 121 | end 122 | 123 | describe "statement" do 124 | @describetag :skip 125 | setup do 126 | {:ok, account_id} = Bank.open_account() 127 | {:ok, empty_account_id} = Bank.open_account() 128 | :ok = Bank.add_funds(account_id, 100) 129 | :ok = Bank.remove_funds(account_id, 100) 130 | [account_id: account_id, empty_account_id: empty_account_id] 131 | end 132 | 133 | test "we can get the statement", ctx do 134 | wait_until(fn -> 135 | {:ok, account_txs} = Bank.get_statement(ctx[:account_id]) 136 | {:ok, empty_account_txs} = Bank.get_statement(ctx[:empty_account_id]) 137 | assert [_, _] = account_txs 138 | assert [ 139 | %{amount: 100}, 140 | %{amount: -100} 141 | ] = account_txs 142 | assert [] = empty_account_txs 143 | end) 144 | end 145 | 146 | test "we can't get the statement for unexistent accounts" do 147 | assert {:error, :not_found} = Bank.get_statement(UUID.uuid4) 148 | end 149 | end 150 | 151 | # Async test helper 152 | defp wait_until(fun), do: wait_until(400, fun) 153 | defp wait_until(t, fun) when t <= 0, do: fun.() 154 | defp wait_until(timeout, fun) do 155 | fun.() 156 | rescue 157 | _ -> 158 | :timer.sleep(10) 159 | wait_until(max(0, timeout - 10), fun) 160 | end 161 | end 162 | --------------------------------------------------------------------------------