├── .ctags ├── .gitignore ├── .iex.exs ├── .travis.yml ├── LICENSE.md ├── Procfile ├── README.md ├── app.json ├── apps ├── auth │ ├── .gitignore │ ├── README.md │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── auth.ex │ │ └── auth │ │ │ ├── account.ex │ │ │ ├── application.ex │ │ │ └── repo.ex │ ├── mix.exs │ ├── priv │ │ └── repo │ │ │ └── migrations │ │ │ └── 20160731211450_create_accounts.exs │ └── test │ │ ├── auth_test.exs │ │ └── test_helper.exs ├── backoffice │ ├── .gitignore │ ├── README.md │ ├── brunch-config.js │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── backoffice.ex │ │ └── backoffice │ │ │ ├── application.ex │ │ │ └── endpoint.ex │ ├── mix.exs │ ├── package.json │ ├── priv │ │ ├── gettext │ │ │ ├── en │ │ │ │ └── LC_MESSAGES │ │ │ │ │ └── errors.po │ │ │ └── errors.pot │ │ └── repo │ │ │ └── seeds.exs │ ├── test │ │ ├── controllers │ │ │ └── page_controller_test.exs │ │ ├── support │ │ │ ├── conn_case.ex │ │ │ └── model_case.ex │ │ ├── test_helper.exs │ │ └── views │ │ │ ├── error_view_test.exs │ │ │ ├── layout_view_test.exs │ │ │ └── page_view_test.exs │ └── web │ │ ├── admin │ │ ├── auth │ │ │ └── account.ex │ │ ├── bank │ │ │ ├── customer.ex │ │ │ └── ledger │ │ │ │ ├── account.ex │ │ │ │ └── entry.ex │ │ └── dashboard.ex │ │ ├── controllers │ │ └── page_controller.ex │ │ ├── gettext.ex │ │ ├── router.ex │ │ ├── static │ │ ├── assets │ │ │ ├── favicon.ico │ │ │ ├── fonts │ │ │ │ ├── FontAwesome.otf │ │ │ │ ├── fontawesome-webfont.eot │ │ │ │ ├── fontawesome-webfont.svg │ │ │ │ ├── fontawesome-webfont.ttf │ │ │ │ ├── fontawesome-webfont.woff │ │ │ │ ├── fontawesome-webfont.woff2 │ │ │ │ ├── ionicons.eot │ │ │ │ ├── ionicons.svg │ │ │ │ ├── ionicons.ttf │ │ │ │ └── ionicons.woff │ │ │ ├── images │ │ │ │ ├── ex_admin │ │ │ │ │ ├── admin_notes_icon.png │ │ │ │ │ ├── datepicker │ │ │ │ │ │ ├── datepicker-header-bg.png │ │ │ │ │ │ ├── datepicker-input-icon.png │ │ │ │ │ │ ├── datepicker-next-link-icon.png │ │ │ │ │ │ ├── datepicker-nipple.png │ │ │ │ │ │ └── datepicker-prev-link-icon.png │ │ │ │ │ ├── glyphicons-halflings-white.png │ │ │ │ │ ├── glyphicons-halflings.png │ │ │ │ │ └── orderable.png │ │ │ │ └── phoenix.png │ │ │ └── robots.txt │ │ ├── css │ │ │ ├── app.css │ │ │ └── phoenix.css │ │ ├── js │ │ │ ├── app.js │ │ │ └── socket.js │ │ └── vendor │ │ │ ├── active_admin.css.css │ │ │ ├── active_admin.css.css.map │ │ │ ├── admin_lte2.css │ │ │ ├── admin_lte2.css.map │ │ │ ├── admin_lte2.js │ │ │ ├── admin_lte2.js.map │ │ │ ├── ex_admin_common.js │ │ │ ├── ex_admin_common.js.map │ │ │ ├── jquery.min.js │ │ │ └── jquery.min.js.map │ │ ├── templates │ │ ├── layout │ │ │ └── app.html.eex │ │ └── page │ │ │ └── index.html.eex │ │ ├── views │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ ├── layout_view.ex │ │ └── page_view.ex │ │ └── web.ex ├── bank │ ├── .gitignore │ ├── .iex.exs │ ├── README.md │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── bank.ex │ │ └── bank │ │ │ ├── application.ex │ │ │ ├── customer.ex │ │ │ ├── customer_registration.ex │ │ │ ├── deposit.ex │ │ │ ├── iex_helpers.ex │ │ │ ├── ledger.ex │ │ │ ├── ledger │ │ │ ├── account.ex │ │ │ └── entry.ex │ │ │ ├── model.ex │ │ │ ├── repo.ex │ │ │ └── transfer.ex │ ├── mix.exs │ ├── priv │ │ └── repo │ │ │ ├── migrations │ │ │ ├── 20160527233040_create_account.exs │ │ │ ├── 20160527233041_create_customer.exs │ │ │ └── 20160528001304_create_entries.exs │ │ │ └── seeds.exs │ └── test │ │ ├── bank │ │ ├── customer_test.exs │ │ ├── ledger │ │ │ └── account_test.exs │ │ ├── ledger_test.exs │ │ ├── transaction_test.exs │ │ └── transfer_test.exs │ │ ├── bank_test.exs │ │ ├── support │ │ └── case.ex │ │ └── test_helper.exs ├── bank_web │ ├── .gitignore │ ├── .iex.exs │ ├── README.md │ ├── brunch-config.js │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── bank_web.ex │ │ └── bank_web │ │ │ ├── application.ex │ │ │ └── endpoint.ex │ ├── mix.exs │ ├── package.json │ ├── priv │ │ └── gettext │ │ │ ├── en │ │ │ └── LC_MESSAGES │ │ │ │ └── errors.po │ │ │ └── errors.pot │ ├── test │ │ ├── controllers │ │ │ ├── account_controller_test.exs │ │ │ ├── page_controller_test.exs │ │ │ └── transfer_controller_test.exs │ │ ├── support │ │ │ ├── channel_case.ex │ │ │ ├── conn_case.ex │ │ │ └── model_case.ex │ │ ├── test_helper.exs │ │ └── views │ │ │ ├── account_view_test.exs │ │ │ ├── error_view_test.exs │ │ │ ├── layout_view_test.exs │ │ │ └── page_view_test.exs │ └── web │ │ ├── controllers │ │ ├── account_controller.ex │ │ ├── authentication.ex │ │ ├── page_controller.ex │ │ ├── session_controller.ex │ │ └── transfer_controller.ex │ │ ├── gettext.ex │ │ ├── router.ex │ │ ├── static │ │ ├── assets │ │ │ ├── favicon.ico │ │ │ ├── images │ │ │ │ └── phoenix.png │ │ │ └── robots.txt │ │ ├── css │ │ │ └── app.css │ │ ├── js │ │ │ ├── app.js │ │ │ └── socket.js │ │ └── vendor │ │ │ └── css │ │ │ └── bootstrap-yeti.min.css │ │ ├── templates │ │ ├── account │ │ │ └── show.html.eex │ │ ├── layout │ │ │ └── app.html.eex │ │ ├── page │ │ │ └── index.html.eex │ │ ├── session │ │ │ └── new.html.eex │ │ └── transfer │ │ │ └── new.html.eex │ │ ├── views │ │ ├── account_view.ex │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ ├── layout_view.ex │ │ ├── page_view.ex │ │ ├── session_view.ex │ │ └── transfer_view.ex │ │ └── web.ex ├── master_proxy │ ├── .gitignore │ ├── README.md │ ├── config │ │ └── config.exs │ ├── lib │ │ ├── master_proxy.ex │ │ └── master_proxy │ │ │ ├── application.ex │ │ │ └── plug.ex │ ├── mix.exs │ └── test │ │ ├── master_proxy_test.exs │ │ └── test_helper.exs ├── messenger │ ├── .gitignore │ ├── .iex.exs │ ├── README.md │ ├── config │ │ └── config.exs │ ├── lib │ │ ├── messenger.ex │ │ └── messenger │ │ │ ├── application.ex │ │ │ ├── logger.ex │ │ │ └── test.ex │ ├── mix.exs │ └── test │ │ ├── messenger_test.exs │ │ └── test_helper.exs └── money │ ├── .gitignore │ ├── .iex.exs │ ├── README.md │ ├── config │ └── config.exs │ ├── lib │ ├── money.ex │ └── money │ │ ├── ecto.ex │ │ └── phoenix.ex │ ├── mix.exs │ └── test │ ├── money_test.exs │ └── test_helper.exs ├── compile ├── config └── config.exs ├── docs ├── diagram.png └── main.md ├── elixir_buildpack.config ├── mix.exs ├── mix.lock ├── phoenix_static_buildpack.config └── script └── diagram.exs /.ctags: -------------------------------------------------------------------------------- 1 | --exclude=.git 2 | --exclude=node_modules 3 | --exclude=doc 4 | --exclude=deps 5 | --exclude=_build 6 | 7 | --langdef=Elixir 8 | --langmap=Elixir:.ex.exs 9 | --regex-Elixir=/^[ \t]*def(p?)[ \t]+([a-z_][a-zA-Z0-9_?!]*)/\2/f,functions,functions (def ...)/ 10 | --regex-Elixir=/^[ \t]*defcallback[ \t]+([a-z_][a-zA-Z0-9_?!]*)/\1/c,callbacks,callbacks (defcallback ...)/ 11 | --regex-Elixir=/^[ \t]*defdelegate[ \t]+([a-z_][a-zA-Z0-9_?!]*)/\1/d,delegates,delegates (defdelegate ...)/ 12 | --regex-Elixir=/^[ \t]*defexception[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/e,exceptions,exceptions (defexception ...)/ 13 | --regex-Elixir=/^[ \t]*defimpl[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/i,implementations,implementations (defimpl ...)/ 14 | --regex-Elixir=/^[ \t]*defmacro(p?)[ \t]+([a-z_][a-zA-Z0-9_?!]*)\(/\2/a,macros,macros (defmacro ...)/ 15 | --regex-Elixir=/^[ \t]*defmacro(p?)[ \t]+([a-zA-Z0-9_?!]+)?[ \t]+([^ \tA-Za-z0-9_]+)[ \t]*[a-zA-Z0-9_!?!]/\3/o,operators,operators (e.g. "defmacro a <<< b")/ 16 | --regex-Elixir=/^[ \t]*defmodule[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/m,modules,modules (defmodule ...)/ 17 | --regex-Elixir=/^[ \t]*defprotocol[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/p,protocols,protocols (defprotocol...)/ 18 | --regex-Elixir=/^[ \t]*Record\.defrecord[ \t]+:([a-zA-Z0-9_]+)/\1/r,records,records (defrecord...)/ 19 | --regex-Elixir=/^[ \t]*test[ \t]+\"([a-z_][a-zA-Z0-9_?! ]*)\"*/\1/t,tests,tests (test ...)/ 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | # The directory Mix will write compiled artifacts to. 3 | /_build 4 | 5 | # If you run "mix test --cover", coverage assets end up here. 6 | /cover 7 | 8 | # The directory Mix downloads your dependencies sources to. 9 | /deps 10 | 11 | # Where 3rd-party dependencies like ExDoc output generated docs. 12 | doc 13 | 14 | # If the VM crashes, it generates a dump, let's ignore it too. 15 | erl_crash.dump 16 | 17 | # Also ignore archive artifacts (built via "mix archive.build"). 18 | *.ez 19 | 20 | app_tree.dot 21 | app_tree.png 22 | deps_tree.dot 23 | deps_tree.png 24 | tags 25 | /diagram.dot 26 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | import_if_available Money, only: [sigil_M: 2] 2 | import_if_available Bank.IExHelpers 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 1.4.2 3 | otp_release: 19.3 4 | sudo: false 5 | addons: 6 | postgresql: 9.4 7 | before_script: 8 | - MIX_ENV=test mix compile 9 | - MIX_ENV=test mix ecto.create 10 | script: 11 | - mix test 12 | - echo "Re-running tests for each app" ; for app in apps/**; do cd $app && mix test; cd ../.. ; done 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Wojciech Mach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: cd apps/master_proxy && MIX_ENV=prod mix run --no-halt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bank Platform 2 | 3 | [![Build Status](https://travis-ci.org/wojtekmach/acme_bank.svg?branch=master)](https://travis-ci.org/wojtekmach/acme_bank) 4 | 5 | 6 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 7 | 8 | Acme Bank is an example project to explore and experiment with building modular and maintainable Elixir/Phoenix applications. For some more context, see ElixirConf USA 2016 talk "Building an Umbrella Project": 9 | 10 | - [Slides](https://speakerdeck.com/wojtekmach/building-an-umbrella-project) 11 | - [Video](https://www.youtube.com/watch?v=6NTmUQClHrU) 12 | 13 | See README of each application to learn more about what it does. Most apps contain a "wishlist" of features that might 14 | makes sense for that app. The idea is not to implement these features but to show how each particular app can grow 15 | in complexity and thus how important it is to keep it separate. 16 | 17 | ## Apps 18 | 19 | - [Auth](apps/auth) 20 | - [Backoffice](apps/backoffice) 21 | - [BankWeb](apps/bank_web) 22 | - [Bank](apps/bank) 23 | - [MasterProxy](apps/master_proxy) 24 | - [Messenger](apps/messenger) 25 | - [Money](apps/money) 26 | 27 | ![diagram](./docs/diagram.png) 28 | 29 | ## Setup 30 | 31 | $ git clone https://github.com/wojtekmach/acme_bank 32 | $ cd acme_bank 33 | $ mix deps.get 34 | $ mix ecto.setup 35 | $ (cd apps/bank_web && npm install) 36 | $ (cd apps/backoffice && npm install) 37 | $ mix phoenix.server 38 | $ open http://localhost:4000 39 | $ open http://localhost:4001/backoffice 40 | 41 | ## Deployment 42 | 43 | Acme Bank can be deployed to Heroku, see the Heroku Deploy button at the beginning of the README. 44 | [MasterProxy](apps/master_proxy) is used as the main entry point to the application: it binds 45 | to the port exposed by Heroku, and forwards requests to web apps. 46 | 47 | ## License 48 | 49 | MIT 50 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bank", 3 | "description": "A simple bank written in Elixir.", 4 | "repository": "https://github.com/wojtekmach/bank", 5 | "keywords": ["elixir", "umbrella", "phoenix", "ecto"], 6 | "buildpacks": [ 7 | { 8 | "url": "https://github.com/HashNuke/heroku-buildpack-elixir.git" 9 | }, 10 | { 11 | "url": "https://github.com/gjaldon/heroku-buildpack-phoenix-static.git" 12 | } 13 | ], 14 | "env": { 15 | "SECRET_KEY_BASE": { 16 | "description": "A secret key for verifying the integrity of signed cookies.", 17 | "generator": "secret" 18 | }, 19 | "POOL_SIZE": { 20 | "description": "Database pool size", 21 | "value": "10" 22 | } 23 | }, 24 | "addons": [ 25 | "heroku-postgresql" 26 | ], 27 | "scripts": { 28 | "postdeploy": "MIX_ENV=prod mix ecto.migrate" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/auth/.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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/auth/README.md: -------------------------------------------------------------------------------- 1 | # Auth 2 | 3 | Authentication system for the platform. 4 | 5 | Right now, `Auth` just handles a simple email/password combination. 6 | 7 | In the future this could be extended to support: 8 | 9 | - different authentication strategies, e.g.: username+password for customers, LDAP for operators, OAuth for partners 10 | - 2FA 11 | - tracking sign in count 12 | - tracking sign in attempts and locking down 13 | - tracking IP 14 | - tracking sign ins from multiple devices 15 | - showing active sessions across devices (using Phoenix Presence) 16 | -------------------------------------------------------------------------------- /apps/auth/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | ## Logger 4 | config :logger, level: :debug 5 | 6 | ## Repo 7 | config :auth, ecto_repos: [Auth.Repo] 8 | 9 | import_config "#{Mix.env}.exs" 10 | -------------------------------------------------------------------------------- /apps/auth/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :auth, Auth.Repo, 4 | adapter: Ecto.Adapters.Postgres, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "bank_platform_#{Mix.env}", 8 | hostname: "localhost" 9 | -------------------------------------------------------------------------------- /apps/auth/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :info 4 | 5 | config :auth, Auth.Repo, 6 | adapter: Ecto.Adapters.Postgres, 7 | url: System.get_env("DATABASE_URL"), 8 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 9 | ssl: true 10 | -------------------------------------------------------------------------------- /apps/auth/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | ## Repo 4 | config :auth, Auth.Repo, 5 | adapter: Ecto.Adapters.Postgres, 6 | username: "postgres", 7 | password: "postgres", 8 | database: "bank_platform_#{Mix.env}", 9 | hostname: "localhost", 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | 12 | ## Comeonin 13 | config :comeonin, :bcrypt_log_rounds, 4 14 | -------------------------------------------------------------------------------- /apps/auth/lib/auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Auth do 2 | @moduledoc ~S""" 3 | Authentication system for the platform. 4 | 5 | See `register/1` for creating an account and `sign_in/2` for signing in. 6 | """ 7 | 8 | alias Auth.{Account, Repo} 9 | 10 | def register(params) do 11 | Account.build(params) 12 | |> Repo.insert() 13 | end 14 | 15 | def sign_in(email, password) do 16 | account = Repo.get_by(Account, email: email) 17 | do_sign_in(account, password) 18 | end 19 | 20 | defp do_sign_in(%Account{password_hash: password_hash} = account, password) do 21 | if Comeonin.Bcrypt.checkpw(password, password_hash) do 22 | {:ok, account} 23 | else 24 | {:error, :unauthorized} 25 | end 26 | end 27 | defp do_sign_in(nil, _) do 28 | Comeonin.Bcrypt.dummy_checkpw() 29 | {:error, :not_found} 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/auth/lib/auth/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Auth.Account do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "auth_accounts" do 6 | field :email, :string 7 | field :password_hash, :string 8 | field :password, :string, virtual: true 9 | 10 | timestamps() 11 | end 12 | 13 | def build(params) do 14 | changeset(%Auth.Account{}, params) 15 | end 16 | 17 | def changeset(account, params \\ %{}) do 18 | cast(account, params, ~w(email password)) 19 | |> validate_required([:email, :password]) 20 | |> validate_format(:email, ~r/.*@.*/) 21 | |> validate_length(:password, min: 8) 22 | |> unique_constraint(:email) 23 | |> put_password_hash() 24 | end 25 | 26 | defp put_password_hash(%{changes: %{password: password}} = changeset) do 27 | put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password)) 28 | end 29 | defp put_password_hash(%{changes: %{}} = changeset), do: changeset 30 | end 31 | -------------------------------------------------------------------------------- /apps/auth/lib/auth/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Auth.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | import Supervisor.Spec, warn: false 6 | 7 | children = [ 8 | supervisor(Auth.Repo, []) 9 | ] 10 | 11 | opts = [strategy: :one_for_one, name: Auth.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/auth/lib/auth/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Auth.Repo do 2 | use Ecto.Repo, otp_app: :auth 3 | end 4 | -------------------------------------------------------------------------------- /apps/auth/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Auth.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :auth, 6 | version: "0.1.0", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: "~> 1.4.2", 12 | build_embedded: Mix.env == :prod, 13 | start_permanent: Mix.env == :prod, 14 | deps: deps(), 15 | docs: [main: "Auth"]] 16 | end 17 | 18 | def application do 19 | [applications: [:logger, :ecto, :postgrex], 20 | mod: {Auth.Application, []}] 21 | end 22 | 23 | defp deps do 24 | [{:ecto, "~> 2.0"}, 25 | {:postgrex, ">= 0.0.0"}, 26 | {:comeonin, "~> 2.5"}] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/auth/priv/repo/migrations/20160731211450_create_accounts.exs: -------------------------------------------------------------------------------- 1 | defmodule Auth.Repo.Migrations.CreateAccounts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:auth_accounts) do 6 | add :email, :string 7 | add :password_hash, :string 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:auth_accounts, [:email]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/auth/test/auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AuthTest do 2 | use ExUnit.Case 3 | 4 | setup _tags do 5 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Auth.Repo, []) 6 | :ok 7 | end 8 | 9 | test "register: success" do 10 | assert {:ok, alice} = Auth.register(%{email: "alice@example.com", password: "secret12"}) 11 | 12 | assert alice.email == "alice@example.com" 13 | assert Comeonin.Bcrypt.checkpw("secret12", alice.password_hash) 14 | end 15 | 16 | test "register: failure" do 17 | {:ok, _} = Auth.register(%{email: "alice@example.com", password: "secret12"}) 18 | {:error, _} = Auth.register(%{email: "alice@example.com", password: "secret12"}) 19 | {:error, _} = Auth.register(%{email: "bob@example.com", password: ""}) 20 | end 21 | 22 | test "sign in" do 23 | {:ok, alice} = Auth.register(%{email: "alice@example.com", password: "secret12"}) 24 | id = alice.id 25 | 26 | assert {:ok, %{id: ^id}} = Auth.sign_in("alice@example.com", "secret12") 27 | assert {:error, :unauthorized} = Auth.sign_in("alice@example.com", "bad") 28 | assert {:error, :not_found} = Auth.sign_in("bad@example.com", "bad") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/auth/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(Auth.Repo, :manual) 4 | -------------------------------------------------------------------------------- /apps/backoffice/.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Static artifacts 11 | /node_modules 12 | 13 | # Since we are building assets from web/static, 14 | # we ignore priv/static. You may want to comment 15 | # this depending on your deployment strategy. 16 | /priv/static/ 17 | 18 | # The config/prod.secret.exs file by default contains sensitive 19 | # data and you should not commit it into version control. 20 | # 21 | # Alternatively, you may comment the line below and commit the 22 | # secrets file as long as you replace its contents by environment 23 | # variables. 24 | /config/prod.secret.exs 25 | -------------------------------------------------------------------------------- /apps/backoffice/README.md: -------------------------------------------------------------------------------- 1 | # Backoffice 2 | 3 | Backoffice provides administrative capabilities to the operators of the platform. 4 | 5 | Built using [ExAdmin](https://github.com/smpallen99/ex_admin). 6 | 7 | ## Setup 8 | 9 | To start your Phoenix app: 10 | 11 | * Install dependencies with `mix deps.get` 12 | * Create and migrate your database with `mix ecto.setup` 13 | * Install Node.js dependencies with `npm install` 14 | * Start Phoenix endpoint with `mix phoenix.server` 15 | 16 | Now you can visit [`localhost:4001`](http://localhost:4001) from your browser. 17 | -------------------------------------------------------------------------------- /apps/backoffice/brunch-config.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | // See http://brunch.io/#documentation for docs. 3 | files: { 4 | javascripts: { 5 | joinTo: { 6 | "js/app.js": /^(web\/static\/js)|(node_modules)/, 7 | "js/ex_admin_common.js": ["web/static/vendor/ex_admin_common.js"], 8 | "js/admin_lte2.js": ["web/static/vendor/admin_lte2.js"], 9 | "js/jquery.min.js": ["web/static/vendor/jquery.min.js"], 10 | } 11 | }, 12 | stylesheets: { 13 | joinTo: { 14 | "css/app.css": /^(web\/static\/css)/, 15 | "css/admin_lte2.css": ["web/static/vendor/admin_lte2.css"], 16 | "css/active_admin.css": ["web/static/vendor/active_admin.css.css"], 17 | }, 18 | order: { 19 | after: ["web/static/css/app.css"] // concat app.css last 20 | } 21 | }, 22 | templates: { 23 | joinTo: "js/app.js" 24 | } 25 | }, 26 | 27 | conventions: { 28 | // This option sets where we should place non-css and non-js assets in. 29 | // By default, we set this to "/web/static/assets". Files in this directory 30 | // will be copied to `paths.public`, which is "priv/static" by default. 31 | assets: /^(web\/static\/assets)/ 32 | }, 33 | 34 | // Phoenix paths configuration 35 | paths: { 36 | // Dependencies and current project directories to watch 37 | watched: [ 38 | "web/static", 39 | "test/static" 40 | ], 41 | 42 | // Where to compile files to 43 | public: "priv/static" 44 | }, 45 | 46 | // Configure your plugins 47 | plugins: { 48 | babel: { 49 | // Do not use ES6 compiler in vendor code 50 | ignore: [/web\/static\/vendor/] 51 | } 52 | }, 53 | 54 | modules: { 55 | autoRequire: { 56 | "js/app.js": ["web/static/js/app"] 57 | } 58 | }, 59 | 60 | npm: { 61 | enabled: true 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /apps/backoffice/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # General application configuration 9 | config :backoffice, 10 | ecto_repos: [] 11 | 12 | # Configures the endpoint 13 | config :backoffice, Backoffice.Endpoint, 14 | url: [host: "localhost", path: "/"], 15 | static_url: [host: "localhost", path: "/backoffice"], 16 | secret_key_base: "oqP7+8kIDrpIetIPddC3hf7pmNxOZbTjqUpWkR6nsvJelI7kfR7bhaBE0PP9pdDa", 17 | render_errors: [view: Backoffice.ErrorView, accepts: ~w(html json)], 18 | pubsub: [name: Backoffice.PubSub, 19 | adapter: Phoenix.PubSub.PG2] 20 | 21 | # Configures Elixir's Logger 22 | config :logger, :console, 23 | format: "$time $metadata[$level] $message\n", 24 | metadata: [:request_id] 25 | 26 | 27 | import_config "#{Mix.env}.exs" 28 | 29 | config :xain, :after_callback, {Phoenix.HTML, :raw} 30 | 31 | config :ex_admin, 32 | # TODO: for now, all DB operations go through Bank.Repo, even managing 33 | # Auth.Account etc (which should be done through Auth.Repo). 34 | # This works, because both Repos use the same DB. (which happens 35 | # to be also useful for Heroku deployment.) 36 | # 37 | # See: https://github.com/smpallen99/ex_admin/issues/138 for a 38 | # proper multi-repo support in ExAdmin. 39 | repo: Bank.Repo, 40 | 41 | module: Backoffice, 42 | modules: [ 43 | Backoffice.ExAdmin.Dashboard, 44 | 45 | Backoffice.ExAdmin.Bank.Customer, 46 | Backoffice.ExAdmin.Bank.LedgerAccount, 47 | Backoffice.ExAdmin.Bank.Ledger.Entry, 48 | 49 | Backoffice.ExAdmin.AuthAccount, 50 | ] 51 | -------------------------------------------------------------------------------- /apps/backoffice/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :backoffice, Backoffice.Endpoint, 10 | http: [port: 4001], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin", 15 | cd: Path.expand("../", __DIR__)]] 16 | 17 | 18 | # Watch static and templates for browser reloading. 19 | config :backoffice, Backoffice.Endpoint, 20 | live_reload: [ 21 | patterns: [ 22 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 23 | ~r{priv/gettext/.*(po)$}, 24 | ~r{web/views/.*(ex)$}, 25 | ~r{web/templates/.*(eex)$} 26 | ] 27 | ] 28 | 29 | # Do not include metadata nor timestamps in development logs 30 | config :logger, :console, format: "[$level] $message\n" 31 | 32 | # Set a higher stacktrace during development. Avoid configuring such 33 | # in production as building large stacktraces may be expensive. 34 | config :phoenix, :stacktrace_depth, 20 35 | -------------------------------------------------------------------------------- /apps/backoffice/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we configure the host to read the PORT 4 | # from the system environment. Therefore, you will need 5 | # to set PORT=80 before running your server. 6 | # 7 | # You should also configure the url host to something 8 | # meaningful, we use this information when generating URLs. 9 | # 10 | # Finally, we also include the path to a manifest 11 | # containing the digested version of static files. This 12 | # manifest is generated by the mix phoenix.digest task 13 | # which you typically run after static files are built. 14 | config :backoffice, Backoffice.Endpoint, 15 | # http: [port: {:system, "PORT"}], 16 | url: [scheme: "https", host: "acme-bank.herokuapp.com", port: 443], 17 | force_ssl: [rewrite_on: [:x_forwarded_proto]], 18 | cache_static_manifest: "priv/static/manifest.json", 19 | secret_key_base: System.get_env("SECRET_KEY_BASE") 20 | 21 | # Do not print debug messages in production 22 | config :logger, level: :info 23 | 24 | # ## SSL Support 25 | # 26 | # To get SSL working, you will need to add the `https` key 27 | # to the previous section and set your `:url` port to 443: 28 | # 29 | # config :backoffice, Backoffice.Endpoint, 30 | # ... 31 | # url: [host: "example.com", port: 443], 32 | # https: [port: 443, 33 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 34 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 35 | # 36 | # Where those two env variables return an absolute path to 37 | # the key and cert in disk or a relative path inside priv, 38 | # for example "priv/ssl/server.key". 39 | # 40 | # We also recommend setting `force_ssl`, ensuring no data is 41 | # ever sent via http, always redirecting to https: 42 | # 43 | # config :backoffice, Backoffice.Endpoint, 44 | # force_ssl: [hsts: true] 45 | # 46 | # Check `Plug.SSL` for all available options in `force_ssl`. 47 | 48 | # ## Using releases 49 | # 50 | # If you are doing OTP releases, you need to instruct Phoenix 51 | # to start the server for all endpoints: 52 | # 53 | # config :phoenix, :serve_endpoints, true 54 | # 55 | # Alternatively, you can configure exactly which server to 56 | # start per endpoint: 57 | # 58 | # config :backoffice, Backoffice.Endpoint, server: true 59 | # 60 | # You will also need to set the application root to `.` in order 61 | # for the new static assets to be served after a hot upgrade: 62 | # 63 | # config :backoffice, Backoffice.Endpoint, root: "." 64 | 65 | # Finally import the config/prod.secret.exs 66 | # which should be versioned separately. 67 | # import_config "prod.secret.exs" 68 | -------------------------------------------------------------------------------- /apps/backoffice/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :backoffice, Backoffice.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | -------------------------------------------------------------------------------- /apps/backoffice/lib/backoffice.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice do 2 | @moduledoc """ 3 | Backoffice provides administrative capabilities to the operators of the platform. 4 | 5 | Built with `ExAdmin`. 6 | """ 7 | end 8 | -------------------------------------------------------------------------------- /apps/backoffice/lib/backoffice/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | children = [ 10 | supervisor(Backoffice.Endpoint, []), 11 | ] 12 | 13 | opts = [strategy: :one_for_one, name: Backoffice.Supervisor] 14 | Supervisor.start_link(children, opts) 15 | end 16 | 17 | def config_change(changed, _new, removed) do 18 | Backoffice.Endpoint.config_change(changed, removed) 19 | :ok 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/backoffice/lib/backoffice/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :backoffice 3 | 4 | # Serve at "/" the static files from "priv/static" directory. 5 | # 6 | # You should set gzip to true if you are running phoenix.digest 7 | # when deploying your static files in production. 8 | plug Plug.Static, 9 | at: "/backoffice", from: :backoffice, gzip: false, 10 | only: ~w(css fonts images js favicon.ico robots.txt) 11 | 12 | # Code reloading can be explicitly enabled under the 13 | # :code_reloader configuration of your endpoint. 14 | if code_reloading? do 15 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 16 | plug Phoenix.LiveReloader 17 | plug Phoenix.CodeReloader 18 | end 19 | 20 | plug Plug.RequestId 21 | plug Plug.Logger 22 | 23 | plug Plug.Parsers, 24 | parsers: [:urlencoded, :multipart, :json], 25 | pass: ["*/*"], 26 | json_decoder: Poison 27 | 28 | plug Plug.MethodOverride 29 | plug Plug.Head 30 | 31 | # The session will be stored in the cookie and signed, 32 | # this means its contents can be read but not tampered with. 33 | # Set :encryption_salt if you would also like to encrypt it. 34 | plug Plug.Session, 35 | store: :cookie, 36 | key: "_backoffice_key", 37 | signing_salt: "ipXaUny2" 38 | 39 | plug Backoffice.Router 40 | end 41 | -------------------------------------------------------------------------------- /apps/backoffice/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :backoffice, 6 | version: "0.0.1", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: "~> 1.4.2", 12 | elixirc_paths: elixirc_paths(Mix.env), 13 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 14 | build_embedded: Mix.env == :prod, 15 | start_permanent: Mix.env == :prod, 16 | aliases: aliases(), 17 | deps: deps()] 18 | end 19 | 20 | def application do 21 | [mod: {Backoffice.Application, []}, 22 | applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, 23 | :phoenix_ecto, :postgrex, :bank]] 24 | end 25 | 26 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 27 | defp elixirc_paths(_), do: ["lib", "web"] 28 | 29 | defp deps do 30 | [{:phoenix, "~> 1.2.0"}, 31 | {:phoenix_pubsub, "~> 1.0"}, 32 | {:phoenix_ecto, "~> 3.0"}, 33 | {:postgrex, ">= 0.0.0"}, 34 | {:phoenix_html, "~> 2.6"}, 35 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 36 | {:gettext, "~> 0.11"}, 37 | {:cowboy, "~> 1.0"}, 38 | {:ex_admin, github: "wojtekmach/ex_admin", branch: "wm-customize-resource-name"}, 39 | {:ex_queb, github: "E-MetroTel/ex_queb", override: true}, 40 | 41 | {:bank, in_umbrella: true}, 42 | ] 43 | end 44 | 45 | defp aliases do 46 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 47 | "ecto.reset": ["ecto.drop", "ecto.setup"], 48 | "test": ["ecto.create --quiet", "ecto.migrate", "test"]] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /apps/backoffice/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "brunch build --production", 6 | "watch": "brunch watch --stdin" 7 | }, 8 | "dependencies": { 9 | "phoenix": "file:../../deps/phoenix", 10 | "phoenix_html": "file:../../deps/phoenix_html" 11 | }, 12 | "devDependencies": { 13 | "babel-brunch": "~6.0.0", 14 | "brunch": "2.7.4", 15 | "clean-css-brunch": "~2.0.0", 16 | "css-brunch": "~2.0.0", 17 | "javascript-brunch": "~2.0.0", 18 | "uglify-js-brunch": "~2.0.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/backoffice/priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_format/3 26 | msgid "has invalid format" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_subset/3 30 | msgid "has an invalid entry" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_exclusion/3 34 | msgid "is reserved" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_confirmation/3 38 | msgid "does not match confirmation" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.no_assoc_constraint/3 42 | msgid "is still associated to this entry" 43 | msgstr "" 44 | 45 | msgid "are still associated to this entry" 46 | msgstr "" 47 | 48 | ## From Ecto.Changeset.validate_length/3 49 | msgid "should be %{count} character(s)" 50 | msgid_plural "should be %{count} character(s)" 51 | msgstr[0] "" 52 | msgstr[1] "" 53 | 54 | msgid "should have %{count} item(s)" 55 | msgid_plural "should have %{count} item(s)" 56 | msgstr[0] "" 57 | msgstr[1] "" 58 | 59 | msgid "should be at least %{count} character(s)" 60 | msgid_plural "should be at least %{count} character(s)" 61 | msgstr[0] "" 62 | msgstr[1] "" 63 | 64 | msgid "should have at least %{count} item(s)" 65 | msgid_plural "should have at least %{count} item(s)" 66 | msgstr[0] "" 67 | msgstr[1] "" 68 | 69 | msgid "should be at most %{count} character(s)" 70 | msgid_plural "should be at most %{count} character(s)" 71 | msgstr[0] "" 72 | msgstr[1] "" 73 | 74 | msgid "should have at most %{count} item(s)" 75 | msgid_plural "should have at most %{count} item(s)" 76 | msgstr[0] "" 77 | msgstr[1] "" 78 | 79 | ## From Ecto.Changeset.validate_number/3 80 | msgid "must be less than %{number}" 81 | msgstr "" 82 | 83 | msgid "must be greater than %{number}" 84 | msgstr "" 85 | 86 | msgid "must be less than or equal to %{number}" 87 | msgstr "" 88 | 89 | msgid "must be greater than or equal to %{number}" 90 | msgstr "" 91 | 92 | msgid "must be equal to %{number}" 93 | msgstr "" 94 | -------------------------------------------------------------------------------- /apps/backoffice/priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_format/3 24 | msgid "has invalid format" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_subset/3 28 | msgid "has an invalid entry" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_exclusion/3 32 | msgid "is reserved" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_confirmation/3 36 | msgid "does not match confirmation" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.no_assoc_constraint/3 40 | msgid "is still associated to this entry" 41 | msgstr "" 42 | 43 | msgid "are still associated to this entry" 44 | msgstr "" 45 | 46 | ## From Ecto.Changeset.validate_length/3 47 | msgid "should be %{count} character(s)" 48 | msgid_plural "should be %{count} character(s)" 49 | msgstr[0] "" 50 | msgstr[1] "" 51 | 52 | msgid "should have %{count} item(s)" 53 | msgid_plural "should have %{count} item(s)" 54 | msgstr[0] "" 55 | msgstr[1] "" 56 | 57 | msgid "should be at least %{count} character(s)" 58 | msgid_plural "should be at least %{count} character(s)" 59 | msgstr[0] "" 60 | msgstr[1] "" 61 | 62 | msgid "should have at least %{count} item(s)" 63 | msgid_plural "should have at least %{count} item(s)" 64 | msgstr[0] "" 65 | msgstr[1] "" 66 | 67 | msgid "should be at most %{count} character(s)" 68 | msgid_plural "should be at most %{count} character(s)" 69 | msgstr[0] "" 70 | msgstr[1] "" 71 | 72 | msgid "should have at most %{count} item(s)" 73 | msgid_plural "should have at most %{count} item(s)" 74 | msgstr[0] "" 75 | msgstr[1] "" 76 | 77 | ## From Ecto.Changeset.validate_number/3 78 | msgid "must be less than %{number}" 79 | msgstr "" 80 | 81 | msgid "must be greater than %{number}" 82 | msgstr "" 83 | 84 | msgid "must be less than or equal to %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than or equal to %{number}" 88 | msgstr "" 89 | 90 | msgid "must be equal to %{number}" 91 | msgstr "" 92 | -------------------------------------------------------------------------------- /apps/backoffice/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Backoffice.Repo.insert!(%Backoffice.SomeModel{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /apps/backoffice/test/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.PageControllerTest do 2 | use Backoffice.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get conn, "/backoffice" 6 | assert html_response(conn, 200) =~ "Welcome to Acme Bank Backoffice" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /apps/backoffice/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | 27 | import Backoffice.Router.Helpers 28 | 29 | # The default endpoint for testing 30 | @endpoint Backoffice.Endpoint 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/backoffice/test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | import Ecto 20 | import Ecto.Changeset 21 | import Ecto.Query 22 | import Backoffice.ModelCase 23 | end 24 | end 25 | 26 | setup _tags do 27 | :ok 28 | end 29 | 30 | @doc """ 31 | Helper for returning list of errors in a struct when given certain data. 32 | 33 | ## Examples 34 | 35 | Given a User schema that lists `:name` as a required field and validates 36 | `:password` to be safe, it would return: 37 | 38 | iex> errors_on(%User{}, %{password: "password"}) 39 | [password: "is unsafe", name: "is blank"] 40 | 41 | You could then write your assertion like: 42 | 43 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 44 | 45 | You can also create the changeset manually and retrieve the errors 46 | field directly: 47 | 48 | iex> changeset = User.changeset(%User{}, password: "password") 49 | iex> {:password, "is unsafe"} in changeset.errors 50 | true 51 | """ 52 | def errors_on(struct, data) do 53 | struct.__struct__.changeset(struct, data) 54 | |> Ecto.Changeset.traverse_errors(&Backoffice.ErrorHelpers.translate_error/1) 55 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /apps/backoffice/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | -------------------------------------------------------------------------------- /apps/backoffice/test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ErrorViewTest do 2 | use Backoffice.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(Backoffice.ErrorView, "404.html", []) == 9 | "Page not found" 10 | end 11 | 12 | test "render 500.html" do 13 | assert render_to_string(Backoffice.ErrorView, "500.html", []) == 14 | "Internal server error" 15 | end 16 | 17 | test "render any other" do 18 | assert render_to_string(Backoffice.ErrorView, "505.html", []) == 19 | "Internal server error" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/backoffice/test/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.LayoutViewTest do 2 | use Backoffice.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /apps/backoffice/test/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.PageViewTest do 2 | use Backoffice.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /apps/backoffice/web/admin/auth/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ExAdmin.AuthAccount do 2 | use ExAdmin.Register 3 | 4 | register_resource Auth.Account do 5 | menu label: "Auth Accounts" 6 | options resource_name: "auth_account", controller_route: "auth_accounts" 7 | 8 | filter [:id, :email] 9 | 10 | index do 11 | column :id 12 | column :email 13 | end 14 | 15 | show _account do 16 | attributes_table do 17 | row :id 18 | row :email 19 | row :inserted_at 20 | row :updated_at 21 | end 22 | end 23 | 24 | form account do 25 | inputs do 26 | input account, :email 27 | 28 | unless account.id do 29 | input account, :password 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/backoffice/web/admin/bank/customer.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ExAdmin.Bank.Customer do 2 | use ExAdmin.Register 3 | 4 | register_resource Bank.Customer do 5 | menu label: "Bank Customers" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /apps/backoffice/web/admin/bank/ledger/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ExAdmin.Bank.LedgerAccount do 2 | use ExAdmin.Register 3 | 4 | register_resource Bank.Ledger.Account do 5 | menu label: "Bank Accounts" 6 | options resource_name: "bank_account", controller_route: "bank_accounts" 7 | 8 | index do 9 | column :id 10 | column :type 11 | column :name 12 | column :currency 13 | column :balance, fn account -> 14 | Bank.Ledger.balance(account) |> Money.to_string 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/backoffice/web/admin/bank/ledger/entry.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ExAdmin.Bank.Ledger.Entry do 2 | use ExAdmin.Register 3 | 4 | register_resource Bank.Ledger.Entry do 5 | menu label: "Bank Entries" 6 | 7 | actions :all, only: [:index] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/backoffice/web/admin/dashboard.ex: -------------------------------------------------------------------------------- 1 | defimpl ExAdmin.Render, for: Money do 2 | defdelegate to_string(money), to: Money 3 | end 4 | 5 | defmodule Backoffice.ExAdmin.Dashboard do 6 | use ExAdmin.Register 7 | 8 | register_page "Dashboard" do 9 | menu priority: 1, label: "Dashboard" 10 | content do 11 | div ".blank_slate_container#dashboard_default_message" do 12 | span ".blank_slate" do 13 | span "Welcome to Acme Bank Backoffice" 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/backoffice/web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.PageController do 2 | use Backoffice.Web, :controller 3 | 4 | def index(conn, _params) do 5 | render conn, "index.html" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /apps/backoffice/web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import Backoffice.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :backoffice 24 | end 25 | -------------------------------------------------------------------------------- /apps/backoffice/web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.Router do 2 | use Backoffice.Web, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | end 11 | 12 | use ExAdmin.Router 13 | 14 | scope "/backoffice", ExAdmin do 15 | pipe_through :browser 16 | admin_routes 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/favicon.ico -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/ionicons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/ionicons.eot -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/ionicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/ionicons.ttf -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/fonts/ionicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/fonts/ionicons.woff -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/admin_notes_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/admin_notes_icon.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-header-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-header-bg.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-input-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-input-icon.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-next-link-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-next-link-icon.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-nipple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-nipple.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-prev-link-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/datepicker/datepicker-prev-link-icon.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/glyphicons-halflings.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/ex_admin/orderable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/ex_admin/orderable.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/backoffice/web/static/assets/images/phoenix.png -------------------------------------------------------------------------------- /apps/backoffice/web/static/assets/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /apps/backoffice/web/static/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ -------------------------------------------------------------------------------- /apps/backoffice/web/static/js/app.js: -------------------------------------------------------------------------------- 1 | // Brunch automatically concatenates all files in your 2 | // watched paths. Those paths can be configured at 3 | // config.paths.watched in "brunch-config.js". 4 | // 5 | // However, those files will only be executed if 6 | // explicitly imported. The only exception are files 7 | // in vendor, which are never wrapped in imports and 8 | // therefore are always executed. 9 | 10 | // Import dependencies 11 | // 12 | // If you no longer want to use a dependency, remember 13 | // to also remove its path from "config.paths.watched". 14 | import "phoenix_html" 15 | 16 | // Import local files 17 | // 18 | // Local files can be imported directly using relative 19 | // paths "./socket" or full ones "web/static/js/socket". 20 | 21 | // import socket from "./socket" 22 | -------------------------------------------------------------------------------- /apps/backoffice/web/static/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "web/static/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket 5 | // and connect at the socket path in "lib/my_app/endpoint.ex": 6 | import {Socket} from "phoenix" 7 | 8 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 9 | 10 | // When you connect, you'll often need to authenticate the client. 11 | // For example, imagine you have an authentication plug, `MyAuth`, 12 | // which authenticates the session and assigns a `:current_user`. 13 | // If the current user exists you can assign the user's token in 14 | // the connection for use in the layout. 15 | // 16 | // In your "web/router.ex": 17 | // 18 | // pipeline :browser do 19 | // ... 20 | // plug MyAuth 21 | // plug :put_user_token 22 | // end 23 | // 24 | // defp put_user_token(conn, _) do 25 | // if current_user = conn.assigns[:current_user] do 26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 27 | // assign(conn, :user_token, token) 28 | // else 29 | // conn 30 | // end 31 | // end 32 | // 33 | // Now you need to pass this token to JavaScript. You can do so 34 | // inside a script tag in "web/templates/layout/app.html.eex": 35 | // 36 | // 37 | // 38 | // You will need to verify the user token in the "connect/2" function 39 | // in "web/channels/user_socket.ex": 40 | // 41 | // def connect(%{"token" => token}, socket) do 42 | // # max_age: 1209600 is equivalent to two weeks in seconds 43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 44 | // {:ok, user_id} -> 45 | // {:ok, assign(socket, :user, user_id)} 46 | // {:error, reason} -> 47 | // :error 48 | // end 49 | // end 50 | // 51 | // Finally, pass the token on connect as below. Or remove it 52 | // from connect if you don't care about authentication. 53 | 54 | socket.connect() 55 | 56 | // Now that you are connected, you can join channels with a topic: 57 | let channel = socket.channel("topic:subtopic", {}) 58 | channel.join() 59 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 60 | .receive("error", resp => { console.log("Unable to join", resp) }) 61 | 62 | export default socket 63 | -------------------------------------------------------------------------------- /apps/backoffice/web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello Backoffice! 11 | "> 12 | 13 | 14 | 15 |
16 |
17 | 22 | 23 |
24 | 25 | 26 | 27 | 28 |
29 | <%= render @view_module, @view_template, assigns %> 30 |
31 | 32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /apps/backoffice/web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%= gettext "Welcome to %{name}", name: "Phoenix!" %>

3 |

A productive web framework that
does not compromise speed and maintainability.

4 |
5 | 6 |
7 |
8 |

Resources

9 | 20 |
21 | 22 |
23 |

Help

24 | 35 |
36 |
37 | -------------------------------------------------------------------------------- /apps/backoffice/web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | if error = form.errors[field] do 13 | content_tag :span, translate_error(error), class: "help-block" 14 | end 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. 25 | # Ecto will pass the :count keyword if the error message is 26 | # meant to be pluralized. 27 | # On your own code and templates, depending on whether you 28 | # need the message to be pluralized or not, this could be 29 | # written simply as: 30 | # 31 | # dngettext "errors", "1 file", "%{count} files", count 32 | # dgettext "errors", "is invalid" 33 | # 34 | if count = opts[:count] do 35 | Gettext.dngettext(Backoffice.Gettext, "errors", msg, msg, count, opts) 36 | else 37 | Gettext.dgettext(Backoffice.Gettext, "errors", msg, opts) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /apps/backoffice/web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.ErrorView do 2 | use Backoffice.Web, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Internal server error" 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render "500.html", assigns 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/backoffice/web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.LayoutView do 2 | use Backoffice.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/backoffice/web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.PageView do 2 | use Backoffice.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/backoffice/web/web.ex: -------------------------------------------------------------------------------- 1 | defmodule Backoffice.Web do 2 | @moduledoc """ 3 | A module that keeps using definitions for controllers, 4 | views and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use Backoffice.Web, :controller 9 | use Backoffice.Web, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. 17 | """ 18 | 19 | def model do 20 | quote do 21 | use Ecto.Schema 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | end 27 | end 28 | 29 | def controller do 30 | quote do 31 | use Phoenix.Controller 32 | 33 | import Ecto 34 | import Ecto.Query 35 | 36 | import Backoffice.Router.Helpers 37 | import Backoffice.Gettext 38 | end 39 | end 40 | 41 | def view do 42 | quote do 43 | use Phoenix.View, root: "web/templates" 44 | 45 | # Import convenience functions from controllers 46 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 47 | 48 | # Use all HTML functionality (forms, tags, etc) 49 | use Phoenix.HTML 50 | 51 | import Backoffice.Router.Helpers 52 | import Backoffice.ErrorHelpers 53 | import Backoffice.Gettext 54 | end 55 | end 56 | 57 | def router do 58 | quote do 59 | use Phoenix.Router 60 | end 61 | end 62 | 63 | @doc """ 64 | When used, dispatch to the appropriate controller/view/etc. 65 | """ 66 | defmacro __using__(which) when is_atom(which) do 67 | apply(__MODULE__, which, []) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /apps/bank/.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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/bank/.iex.exs: -------------------------------------------------------------------------------- 1 | import_file "../../.iex.exs" 2 | -------------------------------------------------------------------------------- /apps/bank/README.md: -------------------------------------------------------------------------------- 1 | # Bank 2 | 3 | Contains main business logic of the platform. 4 | 5 | See: 6 | - [`Bank`](lib/bank.ex) 7 | - [`Bank.Ledger`](lib/bank/ledger.ex) 8 | -------------------------------------------------------------------------------- /apps/bank/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | ## Logger 4 | config :logger, level: :debug 5 | 6 | ## Repo 7 | config :bank, ecto_repos: [Bank.Repo] 8 | 9 | import_config "#{Mix.env}.exs" 10 | -------------------------------------------------------------------------------- /apps/bank/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # TODO: rename DB to bank_* 4 | config :bank, Bank.Repo, 5 | adapter: Ecto.Adapters.Postgres, 6 | username: "postgres", 7 | password: "postgres", 8 | database: "bank_platform_#{Mix.env}", 9 | hostname: "localhost" 10 | 11 | -------------------------------------------------------------------------------- /apps/bank/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :info 4 | 5 | config :bank, Bank.Repo, 6 | adapter: Ecto.Adapters.Postgres, 7 | url: System.get_env("DATABASE_URL"), 8 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 9 | ssl: true 10 | -------------------------------------------------------------------------------- /apps/bank/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :bank, Bank.Repo, 4 | adapter: Ecto.Adapters.Postgres, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "bank_platform_#{Mix.env}", 8 | hostname: "localhost", 9 | pool: Ecto.Adapters.SQL.Sandbox 10 | 11 | -------------------------------------------------------------------------------- /apps/bank/lib/bank.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank do 2 | @moduledoc ~S""" 3 | Contains main business logic of the project. 4 | 5 | `Bank` is used by `BankWeb` and `Backoffice` Phoenix apps. 6 | 7 | See `Bank.Ledger` for a double-entry accounting system implementation. 8 | """ 9 | 10 | use Bank.Model 11 | 12 | ## Customers 13 | 14 | def create_customer!(username, email) do 15 | Customer.build(%{username: username, email: email}) 16 | |> Repo.insert! 17 | end 18 | 19 | def register_customer(username, email, password) do 20 | Bank.CustomerRegistration.create(username, email, password) 21 | end 22 | 23 | def find_customer!(clauses) do 24 | Repo.get_by!(Customer, clauses) 25 | |> Repo.preload(:wallet) 26 | end 27 | 28 | def customers do 29 | Repo.all(Customer) 30 | end 31 | 32 | ## Deposits 33 | 34 | def create_deposit!(account, amount) do 35 | {:ok, result} = 36 | Deposit.build(account, amount) 37 | |> Ledger.write 38 | result 39 | end 40 | 41 | ## Ledger 42 | 43 | @doc ~S""" 44 | Returns balance of the customer's wallet account 45 | """ 46 | def balance(%Customer{wallet: wallet}), do: Ledger.balance(wallet) 47 | 48 | @doc ~S""" 49 | Returns transactions of the customer's wallet account. 50 | """ 51 | def transactions(%Customer{wallet: wallet}), do: Ledger.entries(wallet) 52 | 53 | ## Transfers 54 | 55 | def build_transfer(customer) do 56 | Transfer.changeset(customer, %Transfer{}) 57 | end 58 | 59 | def create_transfer(customer, params) do 60 | Transfer.create(customer, params) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 7 | # for more information on OTP Applications 8 | def start(_type, _args) do 9 | import Supervisor.Spec, warn: false 10 | 11 | # Define workers and child supervisors to be supervised 12 | children = [ 13 | supervisor(Bank.Repo, []) 14 | ] 15 | 16 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 17 | # for other strategies and supported options 18 | opts = [strategy: :one_for_one, name: Bank.Supervisor] 19 | Supervisor.start_link(children, opts) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/customer.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Customer do 2 | use Bank.Model 3 | 4 | schema "bank_customers" do 5 | field :username, :string 6 | field :email, :string 7 | field :auth_account_id, :integer 8 | 9 | belongs_to :wallet, Ledger.Account 10 | 11 | timestamps() 12 | end 13 | 14 | @doc """ 15 | Builds a changeset based on the `struct` and `params`. 16 | """ 17 | def changeset(struct, params \\ %{}) do 18 | struct 19 | |> cast(params, ~w(username email)a) 20 | |> validate_required(~w(username email)a) 21 | |> unique_constraint(:username) 22 | end 23 | 24 | def build(%{username: username} = params) do 25 | changeset(%Customer{}, params) 26 | |> put_assoc(:wallet, Ledger.Account.build_wallet("Wallet: #{username}")) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/customer_registration.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.CustomerRegistration do 2 | use Bank.Model 3 | 4 | def create(username, email, password) do 5 | Ecto.Multi.new 6 | |> Ecto.Multi.insert(:customer, Customer.build(%{username: username, email: email})) 7 | |> Ecto.Multi.run(:account, fn _ -> 8 | Auth.register(%{email: email, password: password}) 9 | end) 10 | |> Ecto.Multi.run(:update, fn %{customer: customer, account: account} -> 11 | Ecto.Changeset.change(customer, auth_account_id: account.id) 12 | |> Repo.update 13 | end) 14 | |> Repo.transaction() 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/deposit.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Deposit do 2 | use Bank.Model 3 | 4 | def build(%Customer{wallet: wallet}, %Money{} = amount) do 5 | build(wallet, amount) 6 | end 7 | def build(%Ledger.Account{} = wallet, %Money{} = amount) do 8 | description = "Deposit" 9 | 10 | [ 11 | {:debit, Ledger.deposits_account, description, amount}, 12 | {:credit, wallet, description, amount}, 13 | ] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/iex_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.IExHelpers do 2 | def alice do 3 | Bank.find_customer!(username: "alice") 4 | end 5 | 6 | def bob do 7 | Bank.find_customer!(username: "bob") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/ledger.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Ledger do 2 | @moduledoc ~S""" 3 | A simple implementation of double-entry accounting system. 4 | 5 | Basically, we store every `Bank.Ledger.Entry` twice - once for each account affected. 6 | Thus, if Alice transfers $10.00 to Bob we'll have two entries: 7 | 8 | - debit Alice's account for $10.00 9 | - credit Bob's account for $10.00 10 | 11 | `Bank.Ledger.Entry` can be a credit or a debit. Depending on `Bank.Ledger.Account`'s type, 12 | a credit can result in the increase (or decrease) of that accounts' balance. 13 | See `balance/1`. 14 | 15 | Double-entry accounting system implementation is usually required for 16 | compliance with other financial institutions. 17 | 18 | See [Wikipedia entry for more information](https://en.wikipedia.org/wiki/Double-entry_bookkeeping_system#Debits_and_credits) 19 | """ 20 | 21 | use Bank.Model 22 | 23 | alias Bank.Ledger.{Account, Entry} 24 | 25 | @doc ~S""" 26 | Creates a wallet account for a given `username`. 27 | """ 28 | def create_wallet!(username) do 29 | Account.build_wallet(username) 30 | |> Repo.insert! 31 | end 32 | 33 | @doc ~S""" 34 | Returns account's balance as `Money`. 35 | 36 | We calculate balance over all account's entry. 37 | Balance increases or decreases are based on `Bank.Account`'s type 38 | and `Bank.Entry`'s type according to this table: 39 | 40 | | Debit | Credit 41 | ----------|----------|--------- 42 | Asset | Increase | Decrease 43 | Liability | Decrease | Increase 44 | 45 | """ 46 | def balance(%Account{id: id, type: type, currency: currency}) do 47 | q = from(t in Entry, 48 | select: fragment("SUM(CASE WHEN b0.type = 'credit' THEN (b0.amount).cents ELSE -(b0.amount).cents END)"), 49 | where: t.account_id == ^id) 50 | 51 | balance = Repo.one(q) || 0 52 | balance = do_balance(balance, type) 53 | %Money{cents: balance, currency: currency} 54 | end 55 | defp do_balance(balance, "liability"), do: +balance 56 | defp do_balance(balance, "asset"), do: -balance 57 | 58 | def deposits_account do 59 | Repo.get_by(Account, name: "Deposits") || 60 | Repo.insert!(Account.build_asset("Deposits")) 61 | end 62 | 63 | def entries(%Account{id: id}) do 64 | Repo.all(from t in Entry, where: t.account_id == ^id) 65 | end 66 | 67 | def write(entries) do 68 | Repo.transaction_with_isolation(fn -> 69 | with :ok <- same_currencies(entries), 70 | {:ok, persisted_entries} <- insert(entries), 71 | :ok <- credits_equal_debits(), 72 | :ok <- sufficient_funds(persisted_entries) do 73 | persisted_entries 74 | else 75 | {:error, reason} -> 76 | Repo.rollback(reason) 77 | end 78 | end, level: :serializable) 79 | end 80 | 81 | defp same_currencies(entries) do 82 | {_, _, _, %Money{currency: currency}} = hd(entries) 83 | currencies = 84 | Enum.flat_map(entries, fn {_, %Account{currency: a}, _, %Money{currency: b}} -> [a, b] end) 85 | 86 | if Enum.uniq(currencies) == [currency] do 87 | :ok 88 | else 89 | {:error, :different_currencies} 90 | end 91 | end 92 | 93 | defp insert(entries) do 94 | entries = 95 | Enum.map(entries, fn tuple -> 96 | Entry.from_tuple(tuple) 97 | |> Repo.insert! 98 | end) 99 | {:ok, entries} 100 | end 101 | 102 | defp credits_equal_debits do 103 | q = from e in Entry, select: fragment("SUM((b0.amount).cents)") 104 | credits = Repo.one!(from(e in q, where: e.type == "credit")) 105 | debits = Repo.one!(from(e in q, where: e.type == "debit")) 106 | 107 | if credits == debits do 108 | :ok 109 | else 110 | {:error, :credits_not_equal_debits} 111 | end 112 | end 113 | 114 | defp sufficient_funds(entries) do 115 | accounts = Enum.map(entries, & &1.account) 116 | 117 | if Enum.all?(accounts, fn account -> balance(account).cents >= 0 end) do 118 | :ok 119 | else 120 | {:error, :insufficient_funds} 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/ledger/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Ledger.Account do 2 | use Bank.Model 3 | 4 | @account_types ~w(asset liability) 5 | 6 | schema "bank_accounts" do 7 | field :type, :string 8 | field :name, :string 9 | field :currency, :string 10 | 11 | timestamps() 12 | end 13 | 14 | def build_asset(name) do 15 | changeset(%Ledger.Account{}, Map.put(%{type: "asset", currency: "USD"}, :name, name)) 16 | end 17 | 18 | def build_wallet(name) do 19 | changeset(%Ledger.Account{}, Map.put(%{type: "liability", currency: "USD"}, :name, name)) 20 | end 21 | 22 | @doc """ 23 | Builds a changeset based on the `struct` and `params`. 24 | """ 25 | def changeset(struct, params \\ %{}) do 26 | struct 27 | |> cast(params, [:type, :name, :currency]) 28 | |> validate_required([:type, :name, :currency]) 29 | |> unique_constraint(:name) 30 | |> validate_inclusion(:type, @account_types) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/ledger/entry.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Ledger.Entry do 2 | use Bank.Model 3 | 4 | @entry_types [:credit, :debit] 5 | 6 | schema "bank_entries" do 7 | field :type, :string 8 | field :description, :string 9 | field :amount, Money.Ecto 10 | belongs_to :account, Ledger.Account 11 | 12 | timestamps() 13 | end 14 | 15 | def from_tuple({type, %Ledger.Account{} = account, description, %Money{} = amount}) 16 | when type in @entry_types and is_binary(description) do 17 | 18 | %Ledger.Entry{ 19 | type: Atom.to_string(type), 20 | account: account, 21 | description: description, 22 | amount: amount, 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/model.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Model do 2 | @moduledoc false 3 | 4 | defmacro __using__(_) do 5 | quote do 6 | use Ecto.Schema 7 | import Ecto.Changeset 8 | import Ecto.Query 9 | 10 | alias Bank.{ 11 | Account, 12 | Customer, 13 | Deposit, 14 | Ledger, 15 | Repo, 16 | Transfer, 17 | Entry 18 | } 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Repo do 2 | @moduledoc false 3 | 4 | use Ecto.Repo, otp_app: :bank 5 | use Scrivener, page_size: 10 6 | 7 | def transaction_with_isolation(fun_or_multi, opts) do 8 | false = Bank.Repo.in_transaction? 9 | level = Keyword.fetch!(opts, :level) 10 | 11 | transaction(fn -> 12 | {:ok, _} = Ecto.Adapters.SQL.query(Bank.Repo, "SET TRANSACTION ISOLATION LEVEL #{level}", []) 13 | 14 | case transaction(fun_or_multi, opts) do 15 | {:ok, result} -> {:ok, result} 16 | {:error, reason} -> Bank.Repo.rollback(reason) 17 | end 18 | |> unwrap_transaction_result 19 | end, opts) 20 | end 21 | 22 | defp unwrap_transaction_result({:ok, result}), do: result 23 | defp unwrap_transaction_result(other), do: other 24 | end 25 | -------------------------------------------------------------------------------- /apps/bank/lib/bank/transfer.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Transfer do 2 | use Bank.Model 3 | 4 | embedded_schema do 5 | field :amount_string, :string 6 | field :amount, Money.Ecto 7 | field :destination_username, :string 8 | field :description, :string 9 | 10 | embeds_one :source_customer, Customer 11 | embeds_one :destination_customer, Customer 12 | end 13 | 14 | def changeset(customer, struct, params \\ %{}) do 15 | struct 16 | |> cast(params, [:amount_string, :destination_username, :description]) 17 | |> validate_required([:amount_string, :destination_username, :description]) 18 | |> validate_format(:amount_string, ~r/\A\d+\.\d{2}\Z/, message: "is invalid") 19 | |> put_embed(:source_customer, customer) 20 | |> put_destination_customer(customer) 21 | end 22 | 23 | defp put_destination_customer(%{valid?: false} = changeset, _), do: changeset 24 | defp put_destination_customer(changeset, source_customer) do 25 | username = get_change(changeset, :destination_username) 26 | 27 | if username == source_customer.username do 28 | add_error(changeset, :destination_username, "cannot transfer to the same account") 29 | else 30 | case Repo.one(from c in Customer, where: c.username == ^username, preload: :wallet) do 31 | %Customer{} = customer -> 32 | put_embed(changeset, :destination_customer, customer) 33 | nil -> 34 | add_error(changeset, :destination_username, "is invalid") 35 | end 36 | end 37 | end 38 | 39 | def create(customer, params) do 40 | changeset = changeset(customer, %Transfer{}, params) 41 | 42 | if changeset.valid? do 43 | transfer = apply_changes(changeset) 44 | source_account = customer.wallet 45 | destination_account = transfer.destination_customer.wallet 46 | 47 | amount = Money.new(transfer.amount_string <> " " <> destination_account.currency) 48 | transfer = %{transfer | amount: amount} 49 | transactions = build_transactions(source_account, destination_account, transfer.description, amount) 50 | 51 | case Ledger.write(transactions) do 52 | {:ok, _} -> 53 | :ok = send_message(transfer) 54 | {:ok, transfer} 55 | {:error, :insufficient_funds} -> 56 | changeset = add_error(changeset, :amount_string, "insufficient funds") 57 | {:error, changeset} 58 | end 59 | else 60 | {:error, changeset} 61 | end 62 | end 63 | 64 | defp build_transactions(source, destination, description, amount) do 65 | [ 66 | {:debit, source, description, amount}, 67 | {:credit, destination, description, amount}, 68 | ] 69 | end 70 | 71 | defp send_message(transfer) do 72 | subject = "You've received #{transfer.amount} from #{transfer.source_customer.username}" 73 | :ok = Messenger.deliver_email(transfer.destination_username, subject, subject) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /apps/bank/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :bank, 6 | version: "0.1.0", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: ">= 1.4.2", 12 | elixirc_paths: elixirc_paths(Mix.env), 13 | build_embedded: Mix.env == :prod, 14 | start_permanent: Mix.env == :prod, 15 | aliases: aliases(), 16 | deps: deps(), 17 | docs: [main: "Bank"]] 18 | end 19 | 20 | # Configuration for the OTP application 21 | # 22 | # Type "mix help compile.app" for more information 23 | def application do 24 | [applications: [:logger, :ecto, :postgrex, :auth, :money], 25 | mod: {Bank.Application, []}] 26 | end 27 | 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | defp deps do 32 | [{:ecto, "~> 2.0"}, 33 | {:postgrex, ">= 0.0.0"}, 34 | {:scrivener_ecto, github: "drewolson/scrivener_ecto"}, 35 | 36 | {:auth, in_umbrella: true}, 37 | {:messenger, in_umbrella: true}, 38 | {:money, in_umbrella: true}] 39 | end 40 | 41 | defp aliases do 42 | ["ecto.setup": ["ecto.create", "ecto.migrate", "ecto.seed"], 43 | "ecto.seed": ["run priv/repo/seeds.exs"], 44 | "ecto.reset": ["ecto.drop", "ecto.setup"], 45 | "test": ["ecto.create --quiet", "ecto.migrate", "test"]] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /apps/bank/priv/repo/migrations/20160527233040_create_account.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.Repo.Migrations.CreateAccount do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:bank_accounts) do 6 | add :type, :string, null: false 7 | add :name, :string, null: false 8 | add :currency, :string, null: false 9 | 10 | timestamps() 11 | end 12 | create index(:bank_accounts, [:name], unique: true) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/bank/priv/repo/migrations/20160527233041_create_customer.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.Repo.Migrations.CreateCustomer do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:bank_customers) do 6 | add :username, :string 7 | add :email, :string 8 | add :wallet_id, references(:bank_accounts, on_delete: :nothing) 9 | add :auth_account_id, :integer 10 | 11 | timestamps() 12 | end 13 | 14 | create index(:bank_customers, [:username], unique: true) 15 | create index(:bank_customers, [:email], unique: true) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/bank/priv/repo/migrations/20160528001304_create_entries.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.Repo.Migrations.CreateEntry do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute """ 6 | CREATE TYPE moneyz AS ( 7 | cents integer, 8 | currency varchar 9 | ); 10 | """ 11 | 12 | create table(:bank_entries) do 13 | add :type, :string 14 | add :description, :string 15 | add :amount, :moneyz 16 | add :account_id, references(:bank_accounts, on_delete: :nothing) 17 | 18 | timestamps() 19 | end 20 | create index(:bank_entries, [:account_id]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/bank/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | if Mix.env == :dev do 2 | 3 | import Money 4 | 5 | {:ok, %{customer: alice}} = Bank.register_customer("alice", "alice@example.com", "secret12") 6 | Bank.create_deposit!(alice, ~M"10 USD") 7 | 8 | {:ok, %{customer: bob}} = Bank.register_customer("bob", "bob@example.com", "secret12") 9 | 10 | end 11 | -------------------------------------------------------------------------------- /apps/bank/test/bank/customer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.CustomerTest do 2 | use Bank.Case 3 | 4 | @valid_attrs %{username: "alice", email: "alice@example.com"} 5 | @invalid_attrs %{} 6 | 7 | test "changeset with valid attributes" do 8 | changeset = Customer.changeset(%Customer{}, @valid_attrs) 9 | assert changeset.valid? 10 | end 11 | 12 | test "changeset with invalid attributes" do 13 | changeset = Customer.changeset(%Customer{}, @invalid_attrs) 14 | refute changeset.valid? 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/bank/test/bank/ledger/account_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.Ledger.AccountTest do 2 | use Bank.Case 3 | alias Bank.Ledger.Account 4 | 5 | @valid_attrs %{type: "liability", name: "some content", currency: "USD"} 6 | @invalid_attrs %{} 7 | 8 | test "changeset with valid attributes" do 9 | changeset = Account.changeset(%Account{}, @valid_attrs) 10 | assert changeset.valid? 11 | end 12 | 13 | test "changeset with invalid attributes" do 14 | changeset = Account.changeset(%Account{}, @invalid_attrs) 15 | refute changeset.valid? 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/bank/test/bank/ledger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.LedgerTest do 2 | use Bank.Case 3 | 4 | @moduletag isolation: :serializable 5 | 6 | setup _tags do 7 | alice = Ledger.create_wallet!("alice") 8 | bob = Ledger.create_wallet!("bob") 9 | {:ok, _} = Deposit.build(alice, ~M"100 USD") |> Ledger.write 10 | 11 | {:ok, %{alice: alice, bob: bob}} 12 | end 13 | 14 | test "write: success", %{alice: alice, bob: bob} do 15 | transactions = [ 16 | {:debit, alice, "", ~M"10 USD"}, 17 | {:credit, bob, "", ~M"10 USD"}, 18 | ] 19 | 20 | assert {:ok, [%Ledger.Entry{}, %Ledger.Entry{}]} = Ledger.write(transactions) 21 | 22 | assert Ledger.balance(alice) == ~M"90 USD" 23 | assert Ledger.balance(bob) == ~M"10 USD" 24 | end 25 | 26 | test "write: different currencies", %{alice: alice, bob: bob} do 27 | transactions = [ 28 | {:debit, alice, "", ~M"10 USD"}, 29 | {:credit, bob, "", ~M"10 EUR"}, 30 | ] 31 | 32 | assert {:error, :different_currencies} = Ledger.write(transactions) 33 | end 34 | 35 | test "write: credits not equal debits", %{alice: alice, bob: bob} do 36 | transactions = [ 37 | {:debit, alice, "", ~M"10 USD"}, 38 | {:credit, bob, "", ~M"9 USD"}, 39 | ] 40 | 41 | assert {:error, :credits_not_equal_debits} = Ledger.write(transactions) 42 | assert Ledger.balance(alice) == ~M"100 USD" 43 | assert Ledger.balance(bob) == ~M"0 USD" 44 | end 45 | 46 | test "write: insufficient funds", %{alice: alice, bob: bob} do 47 | transactions = [ 48 | {:debit, alice, "", ~M"900 USD"}, 49 | {:credit, bob, "", ~M"900 USD"}, 50 | ] 51 | 52 | assert {:error, :insufficient_funds} = Ledger.write(transactions) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /apps/bank/test/bank/transaction_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.EntryTest do 2 | use Bank.Case 3 | end 4 | -------------------------------------------------------------------------------- /apps/bank/test/bank/transfer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.TransferTest do 2 | use Bank.Case 3 | 4 | @moduletag isolation: :serializable 5 | 6 | @valid_params %{ 7 | amount_string: "2.01", 8 | destination_username: "bob", 9 | description: "Lunch money" 10 | } 11 | 12 | setup do 13 | :ok = Messenger.Local.setup() 14 | 15 | alice = Bank.create_customer!("alice", "alice@example.com") 16 | bob = Bank.create_customer!("bob", "bob@example.com") 17 | Bank.create_deposit!(alice, ~M"10 USD") 18 | 19 | {:ok, %{alice: alice, bob: bob}} 20 | end 21 | 22 | test "create: success", %{alice: alice, bob: bob} do 23 | assert {:ok, _} = Transfer.create(alice, @valid_params) 24 | assert Ledger.balance(alice.wallet) == ~M"7.99 USD" 25 | assert Ledger.balance(bob.wallet) == ~M"2.01 USD" 26 | 27 | assert Messenger.Local.subjects_for("bob") == ["You've received 2.01 USD from alice"] 28 | end 29 | 30 | test "create: invalid amount", %{alice: alice} do 31 | assert {:error, %{errors: [amount_string: {"is invalid", _}]}} = 32 | Transfer.create(alice, %{@valid_params | amount_string: "invalid"}) 33 | 34 | assert {:error, %{errors: [amount_string: {"is invalid", _}]}} = 35 | Transfer.create(alice, %{@valid_params | amount_string: "-2.00"}) 36 | end 37 | 38 | test "create: insufficient funds", %{alice: alice} do 39 | assert {:error, %{errors: [amount_string: {"insufficient funds", _}]}} = 40 | Transfer.create(alice, %{@valid_params | amount_string: "999.00"}) 41 | end 42 | 43 | test "create: blank destination", %{alice: alice} do 44 | assert {:error, %{errors: [destination_username: {"can't be blank", _}]}} = 45 | Transfer.create(alice, %{@valid_params | destination_username: ""}) 46 | end 47 | 48 | test "create: cannot transfer to the same account", %{alice: alice} do 49 | assert {:error, %{errors: [destination_username: {"cannot transfer to the same account", _}]}} = 50 | Transfer.create(alice, %{@valid_params | destination_username: "alice"}) 51 | end 52 | 53 | test "create: invalid destination", %{alice: alice} do 54 | assert {:error, %{errors: [destination_username: {"is invalid", _}]}} = 55 | Transfer.create(alice, %{@valid_params | destination_username: "invalid"}) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /apps/bank/test/bank_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /apps/bank/test/support/case.ex: -------------------------------------------------------------------------------- 1 | defmodule Bank.Case do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | alias Bank.Repo 7 | 8 | import Ecto 9 | import Ecto.Changeset 10 | import Ecto.Query 11 | import Bank.Case 12 | 13 | import Money, only: [sigil_M: 2] 14 | use Bank.Model 15 | end 16 | end 17 | 18 | setup tags do 19 | opts = tags |> Map.take([:isolation]) |> Enum.to_list() 20 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Bank.Repo, opts) 21 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Auth.Repo, opts) 22 | 23 | unless tags[:async] do 24 | Ecto.Adapters.SQL.Sandbox.mode(Auth.Repo, {:shared, self()}) 25 | Ecto.Adapters.SQL.Sandbox.mode(Bank.Repo, {:shared, self()}) 26 | end 27 | 28 | :ok 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/bank/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(Bank.Repo, :manual) 4 | -------------------------------------------------------------------------------- /apps/bank_web/.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generate on crash by the VM 8 | erl_crash.dump 9 | 10 | # Static artifacts 11 | /node_modules 12 | 13 | # Since we are building assets from web/static, 14 | # we ignore priv/static. You may want to comment 15 | # this depending on your deployment strategy. 16 | /priv/static/ 17 | 18 | # The config/prod.secret.exs file by default contains sensitive 19 | # data and you should not commit it into version control. 20 | # 21 | # Alternatively, you may comment the line below and commit the 22 | # secrets file as long as you replace its contents by environment 23 | # variables. 24 | /config/prod.secret.exs 25 | -------------------------------------------------------------------------------- /apps/bank_web/.iex.exs: -------------------------------------------------------------------------------- 1 | import_file "../../.iex.exs" 2 | -------------------------------------------------------------------------------- /apps/bank_web/README.md: -------------------------------------------------------------------------------- 1 | # BankWeb 2 | 3 | Web interface to the platform. 4 | 5 | See `Bank` for the main business logic. 6 | 7 | ## Setup 8 | 9 | To start your Phoenix app: 10 | 11 | * Install dependencies with `mix deps.get` 12 | * Create and migrate your database with `mix ecto.setup` 13 | * Install Node.js dependencies with `npm install` 14 | * Start Phoenix endpoint with `mix phoenix.server` 15 | 16 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 17 | -------------------------------------------------------------------------------- /apps/bank_web/brunch-config.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | // See http://brunch.io/#documentation for docs. 3 | files: { 4 | javascripts: { 5 | joinTo: "js/app.js" 6 | 7 | // To use a separate vendor.js bundle, specify two files path 8 | // https://github.com/brunch/brunch/blob/master/docs/config.md#files 9 | // joinTo: { 10 | // "js/app.js": /^(web\/static\/js)/, 11 | // "js/vendor.js": /^(web\/static\/vendor)|(deps)/ 12 | // } 13 | // 14 | // To change the order of concatenation of files, explicitly mention here 15 | // https://github.com/brunch/brunch/tree/master/docs#concatenation 16 | // order: { 17 | // before: [ 18 | // "web/static/vendor/js/jquery-2.1.1.js", 19 | // "web/static/vendor/js/bootstrap.min.js" 20 | // ] 21 | // } 22 | }, 23 | stylesheets: { 24 | joinTo: "css/app.css", 25 | order: { 26 | after: ["web/static/css/app.css"] // concat app.css last 27 | } 28 | }, 29 | templates: { 30 | joinTo: "js/app.js" 31 | } 32 | }, 33 | 34 | conventions: { 35 | // This option sets where we should place non-css and non-js assets in. 36 | // By default, we set this to "/web/static/assets". Files in this directory 37 | // will be copied to `paths.public`, which is "priv/static" by default. 38 | assets: /^(web\/static\/assets)/ 39 | }, 40 | 41 | // Phoenix paths configuration 42 | paths: { 43 | // Dependencies and current project directories to watch 44 | watched: [ 45 | "web/static", 46 | "test/static" 47 | ], 48 | 49 | // Where to compile files to 50 | public: "priv/static" 51 | }, 52 | 53 | // Configure your plugins 54 | plugins: { 55 | babel: { 56 | // Do not use ES6 compiler in vendor code 57 | ignore: [/web\/static\/vendor/] 58 | } 59 | }, 60 | 61 | modules: { 62 | autoRequire: { 63 | "js/app.js": ["web/static/js/app"] 64 | } 65 | }, 66 | 67 | npm: { 68 | enabled: true 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /apps/bank_web/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # General application configuration 9 | config :bank_web, ecto_repos: [] 10 | 11 | # Configures the endpoint 12 | config :bank_web, BankWeb.Endpoint, 13 | url: [host: "localhost"], 14 | secret_key_base: "YhKiB9ImkAB8TdOqawXLwAwVbFklXxnzjqHFRCOeyR2qbwxlmh90E/oxeG894gfu", 15 | render_errors: [view: BankWeb.ErrorView, accepts: ~w(html json)], 16 | pubsub: [name: BankWeb.PubSub, 17 | adapter: Phoenix.PubSub.PG2] 18 | 19 | # Configures Elixir's Logger 20 | config :logger, :console, 21 | format: "$time $metadata[$level] $message\n", 22 | metadata: [:request_id] 23 | 24 | # Import environment specific config. This must remain at the bottom 25 | # of this file so it overrides the configuration defined above. 26 | import_config "#{Mix.env}.exs" 27 | -------------------------------------------------------------------------------- /apps/bank_web/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :bank_web, BankWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin", 15 | cd: Path.expand("../", __DIR__)]] 16 | 17 | 18 | # Watch static and templates for browser reloading. 19 | config :bank_web, BankWeb.Endpoint, 20 | live_reload: [ 21 | patterns: [ 22 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 23 | ~r{priv/gettext/.*(po)$}, 24 | ~r{web/views/.*(ex)$}, 25 | ~r{web/templates/.*(eex)$} 26 | ] 27 | ] 28 | 29 | # Do not include metadata nor timestamps in development logs 30 | config :logger, :console, format: "[$level] $message\n" 31 | 32 | # Set a higher stacktrace during development. Avoid configuring such 33 | # in production as building large stacktraces may be expensive. 34 | config :phoenix, :stacktrace_depth, 20 35 | -------------------------------------------------------------------------------- /apps/bank_web/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we configure the host to read the PORT 4 | # from the system environment. Therefore, you will need 5 | # to set PORT=80 before running your server. 6 | # 7 | # You should also configure the url host to something 8 | # meaningful, we use this information when generating URLs. 9 | # 10 | # Finally, we also include the path to a manifest 11 | # containing the digested version of static files. This 12 | # manifest is generated by the mix phoenix.digest task 13 | # which you typically run after static files are built. 14 | # config :bank_web, BankWeb.Endpoint, 15 | # http: [port: {:system, "PORT"}], 16 | # url: [host: "example.com", port: 80], 17 | # cache_static_manifest: "priv/static/manifest.json" 18 | 19 | config :bank_web, BankWeb.Endpoint, 20 | # http: [port: {:system, "PORT"}], 21 | url: [scheme: "https", host: "acme-bank.herokuapp.com", port: 443], 22 | force_ssl: [rewrite_on: [:x_forwarded_proto]], 23 | cache_static_manifest: "priv/static/manifest.json", 24 | secret_key_base: System.get_env("SECRET_KEY_BASE") 25 | 26 | # Do not print debug messages in production 27 | config :logger, level: :info 28 | 29 | # ## SSL Support 30 | # 31 | # To get SSL working, you will need to add the `https` key 32 | # to the previous section and set your `:url` port to 443: 33 | # 34 | # config :bank_web, BankWeb.Endpoint, 35 | # ... 36 | # url: [host: "example.com", port: 443], 37 | # https: [port: 443, 38 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 39 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 40 | # 41 | # Where those two env variables return an absolute path to 42 | # the key and cert in disk or a relative path inside priv, 43 | # for example "priv/ssl/server.key". 44 | # 45 | # We also recommend setting `force_ssl`, ensuring no data is 46 | # ever sent via http, always redirecting to https: 47 | # 48 | # config :bank_web, BankWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | # ## Using releases 54 | # 55 | # If you are doing OTP releases, you need to instruct Phoenix 56 | # to start the server for all endpoints: 57 | # 58 | # config :phoenix, :serve_endpoints, true 59 | # 60 | # Alternatively, you can configure exactly which server to 61 | # start per endpoint: 62 | # 63 | # config :bank_web, BankWeb.Endpoint, server: true 64 | # 65 | # You will also need to set the application root to `.` in order 66 | # for the new static assets to be served after a hot upgrade: 67 | # 68 | # config :bank_web, BankWeb.Endpoint, root: "." 69 | 70 | # Finally import the config/prod.secret.exs 71 | # which should be versioned separately. 72 | # import_config "prod.secret.exs" 73 | -------------------------------------------------------------------------------- /apps/bank_web/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :bank_web, BankWeb.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | -------------------------------------------------------------------------------- /apps/bank_web/lib/bank_web.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb do 2 | @moduledoc """ 3 | Web interface to the platform built using the `Phoenix` Web framework. 4 | """ 5 | end 6 | -------------------------------------------------------------------------------- /apps/bank_web/lib/bank_web/application.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.Application do 2 | @modulefoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | import Supervisor.Spec 7 | 8 | children = [ 9 | supervisor(BankWeb.Endpoint, []), 10 | ] 11 | 12 | opts = [strategy: :one_for_one, name: BankWeb.Supervisor] 13 | Supervisor.start_link(children, opts) 14 | end 15 | 16 | def config_change(changed, _new, removed) do 17 | BankWeb.Endpoint.config_change(changed, removed) 18 | :ok 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/bank_web/lib/bank_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :bank_web 3 | 4 | # Serve at "/" the static files from "priv/static" directory. 5 | # 6 | # You should set gzip to true if you are running phoenix.digest 7 | # when deploying your static files in production. 8 | plug Plug.Static, 9 | at: "/", from: :bank_web, gzip: false, 10 | only: ~w(css fonts images js favicon.ico robots.txt) 11 | 12 | # Code reloading can be explicitly enabled under the 13 | # :code_reloader configuration of your endpoint. 14 | if code_reloading? do 15 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 16 | plug Phoenix.LiveReloader 17 | plug Phoenix.CodeReloader 18 | end 19 | 20 | plug Plug.RequestId 21 | plug Plug.Logger 22 | 23 | plug Plug.Parsers, 24 | parsers: [:urlencoded, :multipart, :json], 25 | pass: ["*/*"], 26 | json_decoder: Poison 27 | 28 | plug Plug.MethodOverride 29 | plug Plug.Head 30 | 31 | # The session will be stored in the cookie and signed, 32 | # this means its contents can be read but not tampered with. 33 | # Set :encryption_salt if you would also like to encrypt it. 34 | plug Plug.Session, 35 | store: :cookie, 36 | key: "_bank_web_key", 37 | signing_salt: "um/pBJag" 38 | 39 | plug BankWeb.Router 40 | end 41 | -------------------------------------------------------------------------------- /apps/bank_web/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :bank_web, 6 | version: "0.0.1", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: ">= 1.4.2", 12 | elixirc_paths: elixirc_paths(Mix.env), 13 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 14 | build_embedded: Mix.env == :prod, 15 | start_permanent: Mix.env == :prod, 16 | docs: [main: "readme", extras: ["README.md"]], 17 | aliases: aliases(), 18 | deps: deps()] 19 | end 20 | 21 | # Configuration for the OTP application. 22 | # 23 | # Type `mix help compile.app` for more information. 24 | def application do 25 | [mod: {BankWeb.Application, []}, 26 | applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, 27 | :phoenix_ecto, :bank, :messenger, :auth]] 28 | end 29 | 30 | # Specifies which paths to compile per environment. 31 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 32 | defp elixirc_paths(_), do: ["lib", "web"] 33 | 34 | # Specifies your project dependencies. 35 | # 36 | # Type `mix help deps` for examples and options. 37 | defp deps do 38 | [{:phoenix, "~> 1.2"}, 39 | {:phoenix_pubsub, "~> 1.0.0"}, 40 | {:phoenix_ecto, "~> 3.0"}, 41 | {:phoenix_html, "~> 2.5"}, 42 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 43 | {:gettext, "~> 0.11"}, 44 | {:cowboy, "~> 1.0"}, 45 | 46 | {:bank, in_umbrella: true}] 47 | end 48 | 49 | # Aliases are shortcuts or tasks specific to the current project. 50 | # For example, to create, migrate and run the seeds file at once: 51 | # 52 | # $ mix ecto.setup 53 | # 54 | # See the documentation for `Mix` for more info on aliases. 55 | defp aliases do 56 | [] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/bank_web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "brunch build --production", 6 | "watch": "brunch watch --stdin" 7 | }, 8 | "dependencies": { 9 | "phoenix": "file:../../deps/phoenix", 10 | "phoenix_html": "file:../../deps/phoenix_html" 11 | }, 12 | "devDependencies": { 13 | "babel-brunch": "~6.0.0", 14 | "brunch": "2.7.4", 15 | "clean-css-brunch": "~2.0.0", 16 | "css-brunch": "~2.0.0", 17 | "javascript-brunch": "~2.0.0", 18 | "uglify-js-brunch": "~2.0.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/bank_web/priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_format/3 26 | msgid "has invalid format" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_subset/3 30 | msgid "has an invalid entry" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_exclusion/3 34 | msgid "is reserved" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_confirmation/3 38 | msgid "does not match confirmation" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.no_assoc_constraint/3 42 | msgid "is still associated to this entry" 43 | msgstr "" 44 | 45 | msgid "are still associated to this entry" 46 | msgstr "" 47 | 48 | ## From Ecto.Changeset.validate_length/3 49 | msgid "should be %{count} character(s)" 50 | msgid_plural "should be %{count} character(s)" 51 | msgstr[0] "" 52 | msgstr[1] "" 53 | 54 | msgid "should have %{count} item(s)" 55 | msgid_plural "should have %{count} item(s)" 56 | msgstr[0] "" 57 | msgstr[1] "" 58 | 59 | msgid "should be at least %{count} character(s)" 60 | msgid_plural "should be at least %{count} character(s)" 61 | msgstr[0] "" 62 | msgstr[1] "" 63 | 64 | msgid "should have at least %{count} item(s)" 65 | msgid_plural "should have at least %{count} item(s)" 66 | msgstr[0] "" 67 | msgstr[1] "" 68 | 69 | msgid "should be at most %{count} character(s)" 70 | msgid_plural "should be at most %{count} character(s)" 71 | msgstr[0] "" 72 | msgstr[1] "" 73 | 74 | msgid "should have at most %{count} item(s)" 75 | msgid_plural "should have at most %{count} item(s)" 76 | msgstr[0] "" 77 | msgstr[1] "" 78 | 79 | ## From Ecto.Changeset.validate_number/3 80 | msgid "must be less than %{number}" 81 | msgstr "" 82 | 83 | msgid "must be greater than %{number}" 84 | msgstr "" 85 | 86 | msgid "must be less than or equal to %{number}" 87 | msgstr "" 88 | 89 | msgid "must be greater than or equal to %{number}" 90 | msgstr "" 91 | 92 | msgid "must be equal to %{number}" 93 | msgstr "" 94 | -------------------------------------------------------------------------------- /apps/bank_web/priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_format/3 24 | msgid "has invalid format" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_subset/3 28 | msgid "has an invalid entry" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_exclusion/3 32 | msgid "is reserved" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_confirmation/3 36 | msgid "does not match confirmation" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.no_assoc_constraint/3 40 | msgid "is still associated to this entry" 41 | msgstr "" 42 | 43 | msgid "are still associated to this entry" 44 | msgstr "" 45 | 46 | ## From Ecto.Changeset.validate_length/3 47 | msgid "should be %{count} character(s)" 48 | msgid_plural "should be %{count} character(s)" 49 | msgstr[0] "" 50 | msgstr[1] "" 51 | 52 | msgid "should have %{count} item(s)" 53 | msgid_plural "should have %{count} item(s)" 54 | msgstr[0] "" 55 | msgstr[1] "" 56 | 57 | msgid "should be at least %{count} character(s)" 58 | msgid_plural "should be at least %{count} character(s)" 59 | msgstr[0] "" 60 | msgstr[1] "" 61 | 62 | msgid "should have at least %{count} item(s)" 63 | msgid_plural "should have at least %{count} item(s)" 64 | msgstr[0] "" 65 | msgstr[1] "" 66 | 67 | msgid "should be at most %{count} character(s)" 68 | msgid_plural "should be at most %{count} character(s)" 69 | msgstr[0] "" 70 | msgstr[1] "" 71 | 72 | msgid "should have at most %{count} item(s)" 73 | msgid_plural "should have at most %{count} item(s)" 74 | msgstr[0] "" 75 | msgstr[1] "" 76 | 77 | ## From Ecto.Changeset.validate_number/3 78 | msgid "must be less than %{number}" 79 | msgstr "" 80 | 81 | msgid "must be greater than %{number}" 82 | msgstr "" 83 | 84 | msgid "must be less than or equal to %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than or equal to %{number}" 88 | msgstr "" 89 | 90 | msgid "must be equal to %{number}" 91 | msgstr "" 92 | -------------------------------------------------------------------------------- /apps/bank_web/test/controllers/account_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.AccountControllerTest do 2 | use BankWeb.ConnCase 3 | 4 | @moduletag isolation: :serializable 5 | 6 | test "show", %{conn: conn} do 7 | {:ok, %{customer: alice}} = Bank.register_customer("alice", "alice@example.com", "secret12") 8 | Bank.create_deposit!(alice, ~M"10 USD") 9 | 10 | conn = post conn, "/sign_in", %{session: %{email: "alice@example.com", password: "secret12"}} 11 | 12 | conn = get conn, "/account" 13 | assert conn.status == 200 14 | assert conn.resp_body =~ "

Account balance

\n\n$10.00" 15 | assert conn.resp_body =~ "Deposit" 16 | end 17 | 18 | test "unauthenticated", %{conn: conn} do 19 | conn = get conn, "/account" 20 | assert conn.status == 302 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/bank_web/test/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.PageControllerTest do 2 | use BankWeb.ConnCase 3 | 4 | setup do 5 | Bank.create_customer!("alice", "alice@example.com") 6 | :ok 7 | end 8 | 9 | test "index: unauthenticated", %{conn: conn} do 10 | conn = get conn, "/" 11 | assert html_response(conn, 302) =~ ~r{"/sign_in"} 12 | end 13 | 14 | test "index: authenticated", %{conn: conn} do 15 | conn = post conn, "/sign_in_as/alice" 16 | assert html_response(conn, 302) =~ ~r{"/"} 17 | 18 | conn = get conn, "/" 19 | assert html_response(conn, 200) =~ "Signed in as alice" 20 | 21 | conn = get conn, "/sign_out" 22 | assert html_response(conn, 302) =~ ~r{"/"} 23 | 24 | conn = get conn, "/" 25 | assert html_response(conn, 302) =~ ~r{"/sign_in"} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/bank_web/test/controllers/transfer_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.TransferControllerTest do 2 | use BankWeb.ConnCase 3 | 4 | @moduletag isolation: :serializable 5 | 6 | setup do 7 | :ok = Messenger.Local.setup() 8 | 9 | alice = Bank.create_customer!("alice", "alice@example.com") 10 | bob = Bank.create_customer!("bob", "bob@example.com") 11 | Bank.create_deposit!(alice, ~M"10 USD") 12 | 13 | conn = assign(build_conn(), :current_customer, alice) 14 | 15 | {:ok, %{conn: conn, alice: alice, bob: bob}} 16 | end 17 | 18 | test "create: success", %{conn: conn} do 19 | params = %{ 20 | amount_string: "2.01", 21 | destination_username: "bob", 22 | description: "Lunch money" 23 | } 24 | conn = post conn, "/transfers", %{"transfer" => params} 25 | assert html_response(conn, 302) 26 | end 27 | 28 | test "create: invalid amount", %{conn: conn} do 29 | conn = post conn, "/transfers", %{"transfer" => %{amount_string: "bad"}} 30 | assert html_response(conn, 200) =~ "is invalid" 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/bank_web/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | alias Bank.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query 27 | 28 | 29 | # The default endpoint for testing 30 | @endpoint BankWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Bank.Repo) 36 | 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.Sandbox.mode(Bank.Repo, {:shared, self()}) 39 | end 40 | 41 | :ok 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /apps/bank_web/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | 23 | alias Bank.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query 27 | 28 | import BankWeb.Router.Helpers 29 | 30 | import Money, only: [sigil_M: 2] 31 | 32 | # The default endpoint for testing 33 | @endpoint BankWeb.Endpoint 34 | end 35 | end 36 | 37 | setup tags do 38 | opts = tags |> Map.take([:isolation]) |> Enum.to_list() 39 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Bank.Repo, opts) 40 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Auth.Repo, opts) 41 | 42 | unless tags[:async] do 43 | Ecto.Adapters.SQL.Sandbox.mode(Bank.Repo, {:shared, self()}) 44 | Ecto.Adapters.SQL.Sandbox.mode(Auth.Repo, {:shared, self()}) 45 | end 46 | 47 | {:ok, conn: Phoenix.ConnTest.build_conn()} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /apps/bank_web/test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Bank.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import BankWeb.ModelCase 25 | 26 | import Money, only: [sigil_M: 2] 27 | end 28 | end 29 | 30 | setup tags do 31 | opts = tags |> Map.take([:isolation]) |> Enum.to_list() 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Bank.Repo, opts) 33 | # 34 | # unless tags[:async] do 35 | # Ecto.Adapters.SQL.Sandbox.mode(Bank.Repo, {:shared, self()}) 36 | # end 37 | # 38 | # if level = tags[:isolation] do 39 | # Bank.Repo.isolation(level) 40 | # end 41 | 42 | :ok 43 | end 44 | 45 | @doc """ 46 | Helper for returning list of errors in a struct when given certain data. 47 | 48 | ## Examples 49 | 50 | Given a User schema that lists `:name` as a required field and validates 51 | `:password` to be safe, it would return: 52 | 53 | iex> errors_on(%User{}, %{password: "password"}) 54 | [password: "is unsafe", name: "is blank"] 55 | 56 | You could then write your assertion like: 57 | 58 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 59 | 60 | You can also create the changeset manually and retrieve the errors 61 | field directly: 62 | 63 | iex> changeset = User.changeset(%User{}, password: "password") 64 | iex> {:password, "is unsafe"} in changeset.errors 65 | true 66 | """ 67 | def errors_on(struct, data) do 68 | struct.__struct__.changeset(struct, data) 69 | |> Ecto.Changeset.traverse_errors(&BankWeb.ErrorHelpers.translate_error/1) 70 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /apps/bank_web/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(Bank.Repo, :manual) 4 | Ecto.Adapters.SQL.Sandbox.mode(Auth.Repo, :manual) 5 | -------------------------------------------------------------------------------- /apps/bank_web/test/views/account_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.AccountViewTest do 2 | use BankWeb.ConnCase, async: true 3 | doctest BankWeb.AccountView 4 | end 5 | -------------------------------------------------------------------------------- /apps/bank_web/test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.ErrorViewTest do 2 | use BankWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(BankWeb.ErrorView, "404.html", []) == 9 | "Page not found" 10 | end 11 | 12 | test "render 500.html" do 13 | assert render_to_string(BankWeb.ErrorView, "500.html", []) == 14 | "Internal server error" 15 | end 16 | 17 | test "render any other" do 18 | assert render_to_string(BankWeb.ErrorView, "505.html", []) == 19 | "Internal server error" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/bank_web/test/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.LayoutViewTest do 2 | use BankWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /apps/bank_web/test/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.PageViewTest do 2 | use BankWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /apps/bank_web/web/controllers/account_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.AccountController do 2 | use BankWeb.Web, :controller 3 | plug :require_authenticated 4 | 5 | def show(conn, _params) do 6 | customer = conn.assigns.current_customer 7 | balance = Bank.balance(customer) 8 | transactions = Bank.transactions(customer) 9 | 10 | render conn, "show.html", balance: balance, transactions: transactions 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /apps/bank_web/web/controllers/authentication.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.Authentication do 2 | import Plug.Conn 3 | 4 | def init([]), do: [] 5 | 6 | def call(%{assigns: %{current_customer: %{}}} = conn, _opts), do: conn 7 | def call(conn, _opts) do 8 | id = get_session(conn, :customer_id) 9 | customer = if id, do: Bank.find_customer!(id: id) 10 | 11 | conn 12 | |> assign(:current_customer, customer) 13 | end 14 | 15 | def require_authenticated(%{assigns: %{current_customer: %{}}} = conn, _opts), do: conn 16 | def require_authenticated(conn, _opts) do 17 | conn 18 | |> Phoenix.Controller.put_flash(:alert, "You must be signed in to access that page") 19 | |> Phoenix.Controller.redirect(to: "/sign_in") 20 | |> halt() 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/bank_web/web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.PageController do 2 | use BankWeb.Web, :controller 3 | plug :require_authenticated 4 | 5 | def index(conn, _params) do 6 | render conn, "index.html" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /apps/bank_web/web/controllers/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.SessionController do 2 | use BankWeb.Web, :controller 3 | 4 | def new(conn, _params) do 5 | customers = Bank.customers() 6 | render conn, "new.html", customers: customers 7 | end 8 | 9 | def create(conn, %{"session" => %{"email" => email, "password" => password}}) do 10 | case Auth.sign_in(email, password) do 11 | {:ok, account} -> 12 | customer = Bank.find_customer!(auth_account_id: account.id) 13 | 14 | conn 15 | |> put_session(:customer_id, customer.id) 16 | |> redirect(to: "/") 17 | {:error, _} -> 18 | customers = Bank.customers() 19 | render(conn, "new.html", customers: customers) 20 | end 21 | end 22 | 23 | def sign_in_as(conn, %{"username" => username}) do 24 | customer = Bank.find_customer!(username: username) 25 | 26 | conn 27 | |> put_session(:customer_id, customer.id) 28 | |> redirect(to: "/") 29 | end 30 | 31 | def sign_out(conn, _params) do 32 | conn 33 | |> clear_session() 34 | |> redirect(to: "/") 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/bank_web/web/controllers/transfer_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.TransferController do 2 | use BankWeb.Web, :controller 3 | plug :require_authenticated 4 | 5 | def new(conn, _params) do 6 | customer = conn.assigns.current_customer 7 | transfer = Bank.build_transfer(customer) 8 | render conn, "new.html", transfer: transfer 9 | end 10 | 11 | def create(conn, %{"transfer" => transfer_params}) do 12 | customer = conn.assigns.current_customer 13 | 14 | case Bank.create_transfer(customer, transfer_params) do 15 | {:ok, _transfer} -> 16 | redirect conn, to: account_path(conn, :show) 17 | {:error, changeset} -> 18 | changeset = %{changeset | action: :transfer} 19 | render conn, "new.html", transfer: changeset 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/bank_web/web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import BankWeb.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :bank_web 24 | end 25 | -------------------------------------------------------------------------------- /apps/bank_web/web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.Router do 2 | use BankWeb.Web, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | plug BankWeb.Authentication 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", BankWeb do 18 | pipe_through :browser # Use the default browser stack 19 | 20 | get "/", PageController, :index 21 | 22 | get "/sign_in", SessionController, :new 23 | post "/sign_in", SessionController, :create 24 | post "/sign_in_as/:username", SessionController, :sign_in_as 25 | get "/sign_out", SessionController, :sign_out 26 | 27 | get "/account", AccountController, :show 28 | 29 | get "/transfers/new", TransferController, :new 30 | post "/transfers", TransferController, :create 31 | end 32 | 33 | # Other scopes may use custom stacks. 34 | # scope "/api", BankWeb do 35 | # pipe_through :api 36 | # end 37 | end 38 | -------------------------------------------------------------------------------- /apps/bank_web/web/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/bank_web/web/static/assets/favicon.ico -------------------------------------------------------------------------------- /apps/bank_web/web/static/assets/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/apps/bank_web/web/static/assets/images/phoenix.png -------------------------------------------------------------------------------- /apps/bank_web/web/static/assets/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /apps/bank_web/web/static/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ -------------------------------------------------------------------------------- /apps/bank_web/web/static/js/app.js: -------------------------------------------------------------------------------- 1 | // Brunch automatically concatenates all files in your 2 | // watched paths. Those paths can be configured at 3 | // config.paths.watched in "brunch-config.js". 4 | // 5 | // However, those files will only be executed if 6 | // explicitly imported. The only exception are files 7 | // in vendor, which are never wrapped in imports and 8 | // therefore are always executed. 9 | 10 | // Import dependencies 11 | // 12 | // If you no longer want to use a dependency, remember 13 | // to also remove its path from "config.paths.watched". 14 | import "phoenix_html" 15 | 16 | // Import local files 17 | // 18 | // Local files can be imported directly using relative 19 | // paths "./socket" or full ones "web/static/js/socket". 20 | 21 | // import socket from "./socket" 22 | -------------------------------------------------------------------------------- /apps/bank_web/web/static/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "web/static/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket 5 | // and connect at the socket path in "lib/my_app/endpoint.ex": 6 | import {Socket} from "phoenix" 7 | 8 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 9 | 10 | // When you connect, you'll often need to authenticate the client. 11 | // For example, imagine you have an authentication plug, `MyAuth`, 12 | // which authenticates the session and assigns a `:current_user`. 13 | // If the current user exists you can assign the user's token in 14 | // the connection for use in the layout. 15 | // 16 | // In your "web/router.ex": 17 | // 18 | // pipeline :browser do 19 | // ... 20 | // plug MyAuth 21 | // plug :put_user_token 22 | // end 23 | // 24 | // defp put_user_token(conn, _) do 25 | // if current_user = conn.assigns[:current_user] do 26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 27 | // assign(conn, :user_token, token) 28 | // else 29 | // conn 30 | // end 31 | // end 32 | // 33 | // Now you need to pass this token to JavaScript. You can do so 34 | // inside a script tag in "web/templates/layout/app.html.eex": 35 | // 36 | // 37 | // 38 | // You will need to verify the user token in the "connect/2" function 39 | // in "web/channels/user_socket.ex": 40 | // 41 | // def connect(%{"token" => token}, socket) do 42 | // # max_age: 1209600 is equivalent to two weeks in seconds 43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 44 | // {:ok, user_id} -> 45 | // {:ok, assign(socket, :user, user_id)} 46 | // {:error, reason} -> 47 | // :error 48 | // end 49 | // end 50 | // 51 | // Finally, pass the token on connect as below. Or remove it 52 | // from connect if you don't care about authentication. 53 | 54 | socket.connect() 55 | 56 | // Now that you are connected, you can join channels with a topic: 57 | let channel = socket.channel("topic:subtopic", {}) 58 | channel.join() 59 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 60 | .receive("error", resp => { console.log("Unable to join", resp) }) 61 | 62 | export default socket 63 | -------------------------------------------------------------------------------- /apps/bank_web/web/templates/account/show.html.eex: -------------------------------------------------------------------------------- 1 |

Account balance

2 | 3 | <%= format_money(@balance) %> 4 | 5 |

Transactions

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <%= for transaction <- @transactions do %> 17 | 18 | 19 | 20 | 21 | 22 | <% end %> 23 | 24 |
DescriptionTimeAmount
<%= transaction.description %><%= transaction.inserted_at %><%= format_amount(transaction) %>
25 | 26 | <%= link "New transfer", to: transfer_path(@conn, :new), class: "btn btn-default" %> 27 | -------------------------------------------------------------------------------- /apps/bank_web/web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello BankWeb! 11 | "> 12 | 13 | 14 | 15 | 35 | 36 |
37 | <% if flash = get_flash(@conn, :info) do %> 38 | 39 | <% end %> 40 | 41 | <% if flash = get_flash(@conn, :error) do %> 42 | 43 | <% end %> 44 | 45 |
46 | <%= render @view_module, @view_template, assigns %> 47 |
48 | 49 |
50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /apps/bank_web/web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |

Welcome

2 | -------------------------------------------------------------------------------- /apps/bank_web/web/templates/session/new.html.eex: -------------------------------------------------------------------------------- 1 |

Sign in

2 | 3 |
4 |
5 | <%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %> 6 |
7 | <%= label f, :email %>
8 | <%= text_input f, :email, class: "form-control" %> 9 | <%= error_tag f, :email %> 10 |
11 | 12 |
13 | <%= label f, :password %>
14 | <%= password_input f, :password, class: "form-control" %> 15 | <%= error_tag f, :password %> 16 |
17 | 18 |
19 | <%= submit "Sign in", class: "btn btn-primary" %> 20 |
21 | <% end %> 22 |
23 |
24 | 25 |

[Debug] Sign in

26 | 27 | <%= for customer <- @customers do %> 28 | <%= link "Sign in as #{customer.username}", to: session_path(@conn, :sign_in_as, customer.username), method: :post, class: "btn btn-default" %> 29 | <% end %> 30 | -------------------------------------------------------------------------------- /apps/bank_web/web/templates/transfer/new.html.eex: -------------------------------------------------------------------------------- 1 |

New transfer

2 | 3 |
4 |
5 | 6 | <%= form_for @transfer, transfer_path(@conn, :create), fn f -> %> 7 |
8 | <%= label f, :amount_string, "Amount" %>
9 | <%= text_input f, :amount_string, class: "form-control" %> 10 | <%= error_tag f, :amount_string %> 11 |
12 | 13 |
14 | <%= label f, :destination_username, "Destination username" %>
15 | <%= text_input f, :destination_username, class: "form-control" %> 16 | <%= error_tag f, :destination_username %> 17 |
18 | 19 |
20 | <%= label f, :description %>
21 | <%= textarea f, :description, class: "form-control" %> 22 | <%= error_tag f, :description %> 23 |
24 | 25 |
26 | <%= submit "Submit", class: "btn btn-primary" %> 27 |
28 | <% end %> 29 | 30 |
31 |
32 | -------------------------------------------------------------------------------- /apps/bank_web/web/views/account_view.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.AccountView do 2 | use BankWeb.Web, :view 3 | 4 | def format_amount(%Bank.Ledger.Entry{type: type, amount: amount}) do 5 | sign(type) <> format_money(amount) 6 | end 7 | 8 | defp sign("credit"), do: "+" 9 | defp sign("debit"), do: "-" 10 | 11 | def format_money(%Money{} = money) do 12 | [value, currency] = to_string(money) |> String.split(" ", trim: true) 13 | do_format_money(value, currency) 14 | end 15 | 16 | defp do_format_money(value, "USD"), do: "$" <> value 17 | end 18 | -------------------------------------------------------------------------------- /apps/bank_web/web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | if error = form.errors[field] do 13 | content_tag :span, translate_error(error), class: "help-block" 14 | end 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. 25 | # Ecto will pass the :count keyword if the error message is 26 | # meant to be pluralized. 27 | # On your own code and templates, depending on whether you 28 | # need the message to be pluralized or not, this could be 29 | # written simply as: 30 | # 31 | # dngettext "errors", "1 file", "%{count} files", count 32 | # dgettext "errors", "is invalid" 33 | # 34 | if count = opts[:count] do 35 | Gettext.dngettext(BankWeb.Gettext, "errors", msg, msg, count, opts) 36 | else 37 | Gettext.dgettext(BankWeb.Gettext, "errors", msg, opts) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /apps/bank_web/web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.ErrorView do 2 | use BankWeb.Web, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Internal server error" 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render "500.html", assigns 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/bank_web/web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.LayoutView do 2 | use BankWeb.Web, :view 3 | 4 | def nav_link(conn, title, opts) do 5 | active? = conn.request_path == Keyword.fetch!(opts, :to) 6 | class = if active?, do: "active", else: "" 7 | 8 | content_tag(:li, link(title, opts), class: class) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /apps/bank_web/web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.PageView do 2 | use BankWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/bank_web/web/views/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.SessionView do 2 | use BankWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/bank_web/web/views/transfer_view.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.TransferView do 2 | use BankWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/bank_web/web/web.ex: -------------------------------------------------------------------------------- 1 | defmodule BankWeb.Web do 2 | @moduledoc """ 3 | A module that keeps using definitions for controllers, 4 | views and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use BankWeb.Web, :controller 9 | use BankWeb.Web, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. 17 | """ 18 | 19 | def model do 20 | quote do 21 | use Ecto.Schema 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | end 27 | end 28 | 29 | def controller do 30 | quote do 31 | use Phoenix.Controller 32 | 33 | alias Bank.Repo 34 | import Ecto 35 | import Ecto.Query 36 | 37 | import BankWeb.Router.Helpers 38 | import BankWeb.Gettext 39 | 40 | import BankWeb.Authentication, only: [require_authenticated: 2] 41 | end 42 | end 43 | 44 | def view do 45 | quote do 46 | use Phoenix.View, root: "web/templates" 47 | 48 | # Import convenience functions from controllers 49 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 50 | 51 | # Use all HTML functionality (forms, tags, etc) 52 | use Phoenix.HTML 53 | 54 | import BankWeb.Router.Helpers 55 | import BankWeb.ErrorHelpers 56 | import BankWeb.Gettext 57 | end 58 | end 59 | 60 | def router do 61 | quote do 62 | use Phoenix.Router 63 | end 64 | end 65 | 66 | def channel do 67 | quote do 68 | use Phoenix.Channel 69 | 70 | alias Bank.Repo 71 | import Ecto 72 | import Ecto.Query 73 | import BankWeb.Gettext 74 | end 75 | end 76 | 77 | @doc """ 78 | When used, dispatch to the appropriate controller/view/etc. 79 | """ 80 | defmacro __using__(which) when is_atom(which) do 81 | apply(__MODULE__, which, []) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /apps/master_proxy/.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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/master_proxy/README.md: -------------------------------------------------------------------------------- 1 | # MasterProxy 2 | 3 | Proxies requests to Web apps that are part of the platform. 4 | Useful for Heroku deployment when just one web port is exposed. 5 | 6 | This application is based on a gist shared by @Gazler, thanks Gary! 7 | -------------------------------------------------------------------------------- /apps/master_proxy/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :master_proxy, ecto_repos: [] 4 | -------------------------------------------------------------------------------- /apps/master_proxy/lib/master_proxy.ex: -------------------------------------------------------------------------------- 1 | defmodule MasterProxy do 2 | @moduledoc """ 3 | Proxies requests to Web apps that are part of the platform. 4 | Useful for Heroku deployment when just one web port is exposed. 5 | 6 | See `MasterProxy.Plug`. 7 | """ 8 | end 9 | -------------------------------------------------------------------------------- /apps/master_proxy/lib/master_proxy/application.ex: -------------------------------------------------------------------------------- 1 | defmodule MasterProxy.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | import Supervisor.Spec, warn: false 7 | 8 | port = (System.get_env("PORT") || "3333") |> String.to_integer 9 | cowboy = Plug.Adapters.Cowboy.child_spec(:http, MasterProxy.Plug, [], [port: port]) 10 | 11 | children = [ 12 | cowboy 13 | ] 14 | 15 | opts = [strategy: :one_for_one, name: MasterProxy.Supervisor] 16 | Supervisor.start_link(children, opts) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/master_proxy/lib/master_proxy/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule MasterProxy.Plug do 2 | def init(options) do 3 | options 4 | end 5 | 6 | def call(conn, _opts) do 7 | cond do 8 | conn.request_path =~ ~r{/backoffice} -> 9 | Backoffice.Endpoint.call(conn, []) 10 | true -> 11 | BankWeb.Endpoint.call(conn, []) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/master_proxy/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MasterProxy.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :master_proxy, 6 | version: "0.1.0", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: "~> 1.4.2", 12 | build_embedded: Mix.env == :prod, 13 | start_permanent: Mix.env == :prod, 14 | deps: deps()] 15 | end 16 | 17 | def application do 18 | [applications: [:logger, :plug_cowboy, :bank_web, :backoffice], 19 | mod: {MasterProxy.Application, []}] 20 | end 21 | 22 | defp deps do 23 | [{:plug_cowboy, "~> 1.0"}, 24 | {:bank_web, in_umbrella: true}, 25 | {:backoffice, in_umbrella: true}] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/master_proxy/test/master_proxy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MasterProxyTest do 2 | use ExUnit.Case 3 | doctest MasterProxy 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /apps/master_proxy/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/messenger/.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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/messenger/.iex.exs: -------------------------------------------------------------------------------- 1 | import_file "../../.iex.exs" 2 | -------------------------------------------------------------------------------- /apps/messenger/README.md: -------------------------------------------------------------------------------- 1 | # Messenger 2 | 3 | Provides messaging services to the platform. 4 | 5 | Right now, `Messenger` is a dummy, stateless service that just stores the messages in the filesystem. 6 | 7 | In the future this could be extended to support: 8 | 9 | - SMS 10 | - rate-limiting 11 | - bouncing 12 | - idempotence 13 | - failure handling using e.g. retrying with backoff, circuit breakers, fallback providers 14 | - analytics 15 | 16 | See [`Messenger`](lib/messenger.ex) for docs. 17 | -------------------------------------------------------------------------------- /apps/messenger/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :messenger, adapter: Messenger.Local 4 | -------------------------------------------------------------------------------- /apps/messenger/lib/messenger.ex: -------------------------------------------------------------------------------- 1 | defmodule Messenger do 2 | @moduledoc ~S""" 3 | Messenger delivers messages. 4 | 5 | Available adapters: 6 | 7 | - `Messenger.Local` 8 | - `Messenger.Logger` 9 | """ 10 | 11 | @type email :: String.t 12 | @type subject :: String.t 13 | @type body :: String.t 14 | 15 | @callback deliver_email(email, subject, body) :: :ok 16 | 17 | def deliver_email(email, subject, body) do 18 | adapter.deliver_email(email, subject, body) 19 | end 20 | 21 | defp adapter do 22 | Application.get_env(:messenger, :adapter) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/messenger/lib/messenger/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Messenger.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [] 10 | 11 | opts = [strategy: :one_for_one, name: Messenger.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/messenger/lib/messenger/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Messenger.Logger do 2 | require Logger 3 | 4 | def deliver_email(email, subject, body) do 5 | Logger.info("Delivered email:#{inspect email} subject:#{inspect subject} body:#{inspect body}") 6 | :ok 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /apps/messenger/lib/messenger/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Messenger.Local do 2 | @moduledoc ~S""" 3 | Test adapter for `Messenger`. 4 | 5 | Stores messages on the filesystem. Run `setup/0` before each test to 6 | ensure clean state. 7 | """ 8 | 9 | @root "tmp/messenger" 10 | 11 | def setup do 12 | File.rm_rf!(@root) 13 | :ok 14 | end 15 | 16 | def deliver_email(username, subject, body) do 17 | File.mkdir_p!(@root) 18 | 19 | uniq_id = :erlang.unique_integer() 20 | File.write!("#{@root}/#{username}-#{uniq_id}", "#{subject}\n\n#{body}") 21 | :ok 22 | end 23 | 24 | def messages_for(username) do 25 | File.mkdir_p!(@root) 26 | 27 | Path.wildcard("#{@root}/#{username}-*") 28 | |> Enum.map(fn path -> 29 | File.read!(path) 30 | |> String.split("\n\n", parts: 2) 31 | |> List.to_tuple 32 | end) 33 | end 34 | 35 | def subjects_for(username) do 36 | messages_for(username) 37 | |> Enum.map(&elem(&1, 0)) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/messenger/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Messenger.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :messenger, 6 | version: "0.1.0", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: ">= 1.4.2", 12 | build_embedded: Mix.env == :prod, 13 | start_permanent: Mix.env == :prod, 14 | deps: deps(), 15 | docs: [main: "Messenger"]] 16 | end 17 | 18 | def application do 19 | [applications: [:logger], 20 | mod: {Messenger.Application, []}] 21 | end 22 | 23 | defp deps do 24 | [] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /apps/messenger/test/messenger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MessengerTest do 2 | use ExUnit.Case 3 | doctest Messenger 4 | 5 | setup do 6 | :ok = Messenger.Local.setup() 7 | :ok 8 | end 9 | 10 | test "messenger" do 11 | :ok = Messenger.deliver_email("alice", "subject 1", "body 1") 12 | :ok = Messenger.deliver_email("alice", "subject 2", "body 2") 13 | :ok = Messenger.deliver_email("bob", "subject 3", "body 3") 14 | 15 | assert Messenger.Local.subjects_for("alice") == ["subject 2", "subject 1"] 16 | assert Messenger.Local.messages_for("alice") == [ 17 | {"subject 2", "body 2"}, 18 | {"subject 1", "body 1"}, 19 | ] 20 | 21 | assert Messenger.Local.subjects_for("bob") == ["subject 3"] 22 | assert Messenger.Local.messages_for("bob") == [ 23 | {"subject 3", "body 3"}, 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /apps/messenger/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/money/.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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/money/.iex.exs: -------------------------------------------------------------------------------- 1 | import_file "../../.iex.exs" 2 | -------------------------------------------------------------------------------- /apps/money/README.md: -------------------------------------------------------------------------------- 1 | # Money 2 | 3 | Functions to work with monetary values in currencies. See: 4 | 5 | - [`Money`](lib/money.ex) 6 | - [Phoenix integration](lib/money/phoenix.ex) 7 | - [Ecto integration](lib/money/ecto.ex) 8 | -------------------------------------------------------------------------------- /apps/money/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :money, ecto_repos: [] 6 | 7 | # This configuration is loaded before any dependency and is restricted 8 | # to this project. If another project depends on this project, this 9 | # file won't be loaded nor affect the parent project. For this reason, 10 | # if you want to provide default values for your application for 11 | # 3rd-party users, it should be done in your "mix.exs" file. 12 | 13 | # You can configure for your application as: 14 | # 15 | # config :money, key: :value 16 | # 17 | # And access this configuration in your application as: 18 | # 19 | # Application.get_env(:money, :key) 20 | # 21 | # Or configure a 3rd-party app: 22 | # 23 | # config :logger, level: :info 24 | # 25 | 26 | # It is also possible to import configuration files, relative to this 27 | # directory. For example, you can emulate configuration per environment 28 | # by uncommenting the line below and defining dev.exs, test.exs and such. 29 | # Configuration from the imported file will override the ones defined 30 | # here (which is why it is important to import them last). 31 | # 32 | # import_config "#{Mix.env}.exs" 33 | -------------------------------------------------------------------------------- /apps/money/lib/money.ex: -------------------------------------------------------------------------------- 1 | defmodule Money do 2 | @moduledoc ~S""" 3 | `Money` represents some monetary value (stored in cents) in a given currency. 4 | 5 | See `Money.Ecto` for a custom type implementation that can be used in schemas. 6 | 7 | In order to use the `~M` sigil, import the module: 8 | 9 | import Money 10 | 11 | ## Examples 12 | 13 | iex> Money.new("10.00 USD") 14 | ~M"10.00 USD" 15 | 16 | iex> ~M"10.00 USD".currency 17 | "USD" 18 | iex> ~M"10.01 USD".cents 19 | 1001 20 | 21 | iex> Money.add(~M"10 USD", ~M"20 USD") 22 | ~M"30.00 USD" 23 | 24 | iex> Kernel.to_string(~M"-10.50 USD") 25 | "-10.50 USD" 26 | 27 | iex> inspect(~M"10 USD") 28 | "~M\"10.00 USD\"" 29 | 30 | iex> Money.parse("10 USD") 31 | {:ok, ~M"10 USD"} 32 | iex> Money.parse("10.1 USD") 33 | :error 34 | 35 | iex> ~M"10.001 USD" 36 | ** (ArgumentError) invalid string: "10.001 USD" 37 | 38 | """ 39 | 40 | defstruct cents: 0, currency: nil 41 | 42 | def new(str) when is_binary(str) do 43 | case parse(str) do 44 | {:ok, money} -> money 45 | :error -> raise ArgumentError, "invalid string: #{inspect str}" 46 | end 47 | end 48 | 49 | def parse(str) when is_binary(str) do 50 | case Regex.run(~r/\A(-?)(\d+)(\.(\d{2}))?\ ([A-Z]{3})\z/, str) do 51 | [_, sign, dollars, _, cents, currency] -> 52 | do_parse(sign, dollars, cents, currency) 53 | _ -> :error 54 | end 55 | end 56 | 57 | defp do_parse(sign, dollars, "", currency), 58 | do: do_parse(sign, dollars, "00", currency) 59 | defp do_parse(sign, dollars, cents, currency) do 60 | sign = if sign == "-", do: -1, else: 1 61 | cents = sign * (String.to_integer(dollars) * 100 + String.to_integer(cents)) 62 | {:ok, %Money{cents: cents, currency: currency}} 63 | end 64 | 65 | def sigil_M(str, _opts), 66 | do: new(str) 67 | 68 | def add(%Money{cents: left_cents, currency: currency}, 69 | %Money{cents: right_cents, currency: currency}) do 70 | %Money{cents: left_cents + right_cents, currency: currency} 71 | end 72 | 73 | def to_string(%Money{cents: cents, currency: currency}) when cents >= 0 do 74 | {dollars, cents} = {div(cents, 100), rem(cents, 100)} 75 | cents = :io_lib.format("~2..0B", [cents]) |> IO.iodata_to_binary 76 | "#{dollars}.#{cents} #{currency}" 77 | end 78 | def to_string(%Money{cents: cents, currency: currency}) do 79 | "-" <> Money.to_string(%Money{cents: -cents, currency: currency}) 80 | end 81 | end 82 | 83 | defimpl Inspect, for: Money do 84 | def inspect(money, _opts), 85 | do: "~M\"#{money}\"" 86 | end 87 | 88 | defimpl String.Chars, for: Money do 89 | defdelegate to_string(data), to: Money 90 | end 91 | -------------------------------------------------------------------------------- /apps/money/lib/money/ecto.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Type) do 2 | defmodule Money.Ecto do 3 | @moduledoc ~S""" 4 | 5 | Provides custom Ecto type to use `Money`. 6 | 7 | It might work with different adapters, but it has only been tested 8 | on PostgreSQL as a composite type. 9 | 10 | ## Usage: 11 | 12 | Schema: 13 | 14 | defmodule Item do 15 | use Ecto.Schema 16 | 17 | schema "items" do 18 | field :name, :string 19 | field :price, Money.Ecto 20 | end 21 | end 22 | 23 | Migration: 24 | 25 | def change do 26 | execute " 27 | CREATE TYPE moneyz AS ( 28 | cents integer, 29 | currency varchar 30 | ); 31 | " 32 | 33 | create table(:items) do 34 | add :name, :string 35 | add :price, :moneyz 36 | end 37 | end 38 | 39 | """ 40 | 41 | @behaviour Ecto.Type 42 | 43 | def type, do: :moneyz 44 | 45 | # TODO: 46 | def cast(_), do: :error 47 | 48 | def load({cents, currency}) when is_integer(cents) and is_binary(currency) do 49 | {:ok, %Money{cents: cents, currency: currency}} 50 | end 51 | def load(_), do: :error 52 | 53 | def dump(%Money{cents: cents, currency: currency}) do 54 | {:ok, {cents, currency}} 55 | end 56 | def dump(_), do: :error 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/money/lib/money/phoenix.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Phoenix.HTML.Safe) do 2 | defimpl Phoenix.HTML.Safe, for: Money do 3 | defdelegate to_iodata(data), to: Money, as: :to_string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /apps/money/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :money, 6 | version: "0.1.0", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: ">= 1.4.2", 12 | build_embedded: Mix.env == :prod, 13 | start_permanent: Mix.env == :prod, 14 | deps: deps(), 15 | docs: [main: "Money"]] 16 | end 17 | 18 | def application do 19 | [applications: [:logger]] 20 | end 21 | 22 | defp deps do 23 | [{:ecto, "~> 2.0", optional: true}, 24 | {:phoenix_html, "~> 2.6", optional: true}, 25 | {:plug, "~> 1.2", optional: true}] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/money/test/money_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MoneyTest do 2 | use ExUnit.Case 3 | doctest Money, import: true 4 | import Money, only: [sigil_M: 2] 5 | 6 | test "works" do 7 | assert ~M"10 USD" == %Money{cents: 10_00, currency: "USD"} 8 | assert ~M"10.00 USD" == %Money{cents: 10_00, currency: "USD"} 9 | assert ~M"-5.10 USD" == %Money{cents: -5_10, currency: "USD"} 10 | 11 | assert to_string(~M"10 USD") == "10.00 USD" 12 | assert to_string(~M"10.00 USD") == "10.00 USD" 13 | assert to_string(~M"-5.10 USD") == "-5.10 USD" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/money/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /compile: -------------------------------------------------------------------------------- 1 | # apps/bank_web (default) 2 | brunch build --production 3 | mix phoenix.digest 4 | 5 | # apps/backoffice 6 | cd ../backoffice 7 | npm install 8 | brunch build --production 9 | mix phoenix.digest 10 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # By default, the umbrella project as well as each child 6 | # application will require this configuration file, ensuring 7 | # they all use the same configuration. While one could 8 | # configure all applications here, we prefer to delegate 9 | # back to each application for organization purposes. 10 | import_config "../apps/*/config/config.exs" 11 | 12 | # Sample configuration (overrides the imported configuration above): 13 | # 14 | # config :logger, :console, 15 | # level: :info, 16 | # format: "$date $time [$level] $metadata$message\n", 17 | # metadata: [:user_id] 18 | -------------------------------------------------------------------------------- /docs/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojtekmach/acme_bank/cee19a490d2b3c04465273b7a6212d7e6a81f736/docs/diagram.png -------------------------------------------------------------------------------- /docs/main.md: -------------------------------------------------------------------------------- 1 | # Acme Bank 2 | 3 | Acme Bank is an example project to explore and experiment with building modular and maintainable Elixir/Phoenix applications. 4 | 5 | See [README on GitHub](https://github.com/wojtekmach/acme_bank) for more information, including setup instructions. 6 | 7 | ## Apps 8 | 9 | - `Auth` 10 | - `Backoffice` 11 | - `BankWeb` 12 | - `Bank` 13 | - `MasterProxy` 14 | - `Messenger` 15 | - `Money` 16 | 17 | ![diagram](assets/diagram.png) 18 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | erlang_version=19.3 2 | elixir_version=1.4.2 3 | 4 | config_vars_to_export=(DATABASE_URL) 5 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BankPlatform.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [apps_path: "apps", 6 | build_embedded: Mix.env == :prod, 7 | start_permanent: Mix.env == :prod, 8 | version: "1.0.0-dev", 9 | source_url: "https://github.com/wojtekmach/acme_bank", 10 | name: "Acme Bank", 11 | docs: [source_ref: "HEAD", main: "main", assets: "docs", extras: ["docs/main.md"]], 12 | deps: deps(), 13 | aliases: aliases()] 14 | end 15 | 16 | defp deps do 17 | [{:ex_doc, github: "elixir-lang/ex_doc", branch: "master", only: :dev}] 18 | end 19 | 20 | defp aliases do 21 | ["ecto.setup": ["ecto.create", "ecto.migrate", "ecto.seed"], 22 | "ecto.seed": ["run apps/bank/priv/repo/seeds.exs"], 23 | "ecto.reset": ["ecto.drop", "ecto.setup"], 24 | "test": ["ecto.create --quiet", "ecto.migrate", "test"]] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "comeonin": {:hex, :comeonin, "2.5.3", "ccd70ebf465eaf4e11fc5a13fd0f5be2538b6b4975d4f7a13d571670b31da060", [:make, :mix], []}, 3 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, 4 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 6 | "csvlixir": {:hex, :csvlixir, "1.0.0", "e9fd30abfca2d312390060e86bb7ec52487c813824dcccad45bb13e85ecad6b1", [:mix], []}, 7 | "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"}, 8 | "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, 9 | "earmark": {:hex, :earmark, "1.0.2", "a0b0904d74ecc14da8bd2e6e0248e1a409a2bc91aade75fcf428125603de3853", [:mix], []}, 10 | "ecto": {:hex, :ecto, "2.0.5", "7f4c79ac41ffba1a4c032b69d7045489f0069c256de606523c65d9f8188e502d", [:mix], [{:db_connection, "~> 1.0-rc.4", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.1.2 or ~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.7.7", [hex: :mariaex, optional: true]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.12.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0-beta", [hex: :sbroker, optional: true]}]}, 11 | "ex_admin": {:git, "https://github.com/wojtekmach/ex_admin.git", "5912810cfd048df6cf483ffeccf4c05355a2cb5e", [branch: "wm-customize-resource-name"]}, 12 | "ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "316a7fb475c51fc8949f7b41472d14973dd8d6ab", [branch: "master"]}, 13 | "ex_queb": {:git, "https://github.com/E-MetroTel/ex_queb.git", "1a03bdc6bd4df6d79381f180a3d7600292efb48d", []}, 14 | "exactor": {:hex, :exactor, "2.2.2", "90b27d72c05614801a60f8400afd4e4346dfc33ea9beffe3b98a794891d2ff96", [:mix], []}, 15 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, 16 | "gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], []}, 17 | "inflex": {:hex, :inflex, "1.7.0", "4466a34b7d8e871d8164619ba0f3b8410ec782e900f0ae1d3d27a5875a29532e", [:mix], []}, 18 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 19 | "phoenix": {:hex, :phoenix, "1.2.1", "6dc592249ab73c67575769765b66ad164ad25d83defa3492dc6ae269bd2a68ab", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.1", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]}, 20 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.0.1", "42eb486ef732cf209d0a353e791806721f33ff40beab0a86f02070a5649ed00a", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, optional: false]}, {:phoenix_html, "~> 2.6", [hex: :phoenix_html, optional: true]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}]}, 21 | "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.5", "829218c4152ba1e9848e2bf8e161fcde6b4ec679a516259442561d21fde68d0b", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2-rc", [hex: :phoenix, optional: false]}]}, 23 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.1", "c10ddf6237007c804bf2b8f3c4d5b99009b42eca3a0dfac04ea2d8001186056a", [:mix], []}, 24 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, 25 | "plug_cowboy": {:hex, :plug_cowboy, "1.0.0", "2e2a7d3409746d335f451218b8bb0858301c3de6d668c3052716c909936eb57a", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 26 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 27 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}, 28 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 29 | "postgrex": {:hex, :postgrex, "0.12.2", "13cfd784a148da78f0edddd433bcef5c98388fdb2963ba25ed1fa7265885e6bf", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, 30 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, 31 | "scrivener": {:hex, :scrivener, "2.1.1", "eb52c8b7d283e8999edd6fd50d872ab870669d1f4504134841d0845af11b5ef3", [:mix], []}, 32 | "scrivener_ecto": {:git, "https://github.com/drewolson/scrivener_ecto.git", "df2c5720f763b002c48be1f132fbaaa594cfbeae", []}, 33 | "xain": {:hex, :xain, "0.6.0", "5b61cfe3ffc17904759ee30a699f9e0b1aefd943e996ee4cafea76e5b2f59e3a", [:mix], []}, 34 | } 35 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | phoenix_relative_path=apps/bank_web 2 | -------------------------------------------------------------------------------- /script/diagram.exs: -------------------------------------------------------------------------------- 1 | deps = Mix.Dep.loaded([]) |> Enum.filter(& &1.top_level) 2 | 3 | fun = fn dep, deps -> 4 | dep = Enum.find(deps, & &1.app == dep.app) 5 | children = Enum.filter(dep.deps, & Keyword.get(&1.opts, :in_umbrella)) 6 | {{dep.app, nil}, children} 7 | end 8 | 9 | dep = Enum.at(deps, -1) 10 | 11 | Mix.Utils.print_tree([dep], fn dep -> fun.(dep, deps) end) 12 | Mix.Utils.write_dot_graph!("tmp/diagram.dot", "deps", [dep], fn dep -> fun.(dep, deps) end) 13 | 14 | System.cmd("dot", ["-Tpng", "tmp/diagram.dot", "-odocs/diagram.png"]) 15 | --------------------------------------------------------------------------------