Initial commit

This commit is contained in:
Mikko Ahlroth 2019-05-27 06:02:42 +02:00
commit e6cbccaf6f
19 changed files with 433 additions and 0 deletions

4
backend/.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

24
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
week_budget-*.tar

21
backend/README.md Normal file
View file

@ -0,0 +1,21 @@
# WeekBudget
**TODO: Add description**
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `week_budget` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:week_budget, "~> 0.1.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/week_budget](https://hexdocs.pm/week_budget).

38
backend/config/config.exs Normal file
View file

@ -0,0 +1,38 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
use Mix.Config
# This configuration is loaded before any dependency and is restricted
# to this project. If another project depends on this project, this
# file won't be loaded nor affect the parent project. For this reason,
# if you want to provide default values for your application for
# third-party users, it should be done in your "mix.exs" file.
# You can configure your application as:
#
# config :week_budget, key: :value
#
# and access this configuration in your application as:
#
# Application.get_env(:week_budget, :key)
#
# You can also configure a third-party app:
#
# config :logger, level: :info
#
# It is also possible to import configuration files, relative to this
# directory. For example, you can emulate configuration per environment
# by uncommenting the line below and defining dev.exs, test.exs and such.
# Configuration from the imported file will override the ones defined
# here (which is why it is important to import them last).
#
# import_config "#{Mix.env()}.exs"
config :week_budget,
ecto_repos: [WeekBudget.DB.Repo],
database_url: "ecto://weekbudget:week@127.0.0.1/weekbudget"
config :ex_money,
exchange_rates_retrieve_every: :never,
default_cldr_backend: WeekBudget.Core.Cldr

87
backend/lib/api/server.ex Normal file
View file

@ -0,0 +1,87 @@
defmodule WeekBudget.API.Server do
@moduledoc """
Simple server handling/delegating all API calls of the system.
"""
require Logger
use Ace.HTTP.Service, port: 2019, cleartext: true
use Raxx.SimpleServer
@impl Raxx.SimpleServer
@spec handle_request(Raxx.Request.t(), %{}) :: Raxx.Response.t()
def handle_request(req, state)
def handle_request(%{method: :GET, path: ["budgets", uuid]}, _state) when uuid != "" do
with {:ok, _} <- Ecto.UUID.dump(uuid),
%WeekBudget.DB.Budget{} = wb <- WeekBudget.DB.Budget.get_by_secret(uuid) do
wb
|> serialize_budget()
|> json_resp()
else
:error -> json_resp(:bad_request, %{error: "Malformed UUID."})
nil -> json_resp(:not_found, %{error: "Budget not found."})
end
end
def handle_request(%{method: :POST, path: ["budgets", uuid, "events"], body: body}, _state)
when uuid != "" and body != "" do
with {:uuid, {:ok, _}} <- {:uuid, Ecto.UUID.dump(uuid)},
{:wb, %WeekBudget.DB.Budget{} = wb} <- {:wb, WeekBudget.DB.Budget.get_by_secret(uuid)},
{:json, {:ok, %{"amount" => amnt}}} when is_number(amnt) <- {:json, Jason.decode(body)},
{:currency, {:ok, curr}} <- {:currency, Money.validate_currency(wb.amount.currency)},
{:money, %Money{} = money} <- {:money, WeekBudget.Core.MoneyUtils.create(curr, -amnt)} do
{:ok, event} = WeekBudget.DB.Event.create(wb, money)
event_json = serialize_event(event)
json_resp(:created, event_json)
else
{:uuid, _} -> json_resp(:bad_request, %{error: "Malformed UUID."})
{:wb, _} -> json_resp(:not_found, %{error: "Budget not found."})
{:json, _} -> json_resp(:bad_request, %{error: "Cannot parse JSON."})
{:currency, _} -> json_resp(:bad_request, %{error: "Invalid currency."})
{:money, _} -> json_resp(:internal_server_error, %{error: "Cannot create money."})
end
end
def handle_request(%{method: :POST, path: ["budgets"], body: body}, _state) when body != "" do
with {:json, {:ok, %{"amount" => amnt, "currency" => curr_str}}}
when is_number(amnt) and is_binary(curr_str) <- {:json, Jason.decode(body)},
{:currency, {:ok, curr}} <- {:currency, Money.validate_currency(curr_str)},
{:money, %Money{} = money} <- {:money, WeekBudget.Core.MoneyUtils.create(curr, amnt)} do
{:ok, budget} = WeekBudget.DB.Budget.create(money)
budget_json = serialize_budget(%WeekBudget.DB.Budget{budget | events: []})
json_resp(:created, budget_json)
else
{:json, _} -> json_resp(:bad_request, %{error: "Cannot parse JSON."})
{:currency, _} -> json_resp(:bad_request, %{error: "Invalid currency."})
{:money, _} -> json_resp(:internal_server_error, %{error: "Cannot create money."})
end
end
def handle_request(req, _) do
Logger.debug("Failed request:")
Logger.debug(inspect(req))
json_resp(:not_found, %{error: "Unknown API path or invalid data."})
end
defp serialize_budget(%WeekBudget.DB.Budget{secret: secret, amount: amount, events: events}) do
# True amount is initial amount plus all event amounts
true_amount = Enum.reduce(events, amount, &Money.add!(&2, &1.amount))
%{
uuid: secret,
init: Decimal.to_float(amount.amount),
amount: Decimal.to_float(true_amount.amount),
events: Enum.map(events, &serialize_event/1)
}
end
defp serialize_event(%WeekBudget.DB.Event{at: at, amount: amount}) do
%{at: DateTime.to_iso8601(at), amount: Decimal.to_float(amount.amount)}
end
defp json_resp(status \\ :ok, content) do
response(status)
|> set_header("content-type", "application/json")
|> set_body(Jason.encode!(content))
end
end

View file

@ -0,0 +1,30 @@
defmodule WeekBudget.Core.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
def start(_type, _args) do
case Code.ensure_loaded(ExSync) do
{:module, ExSync = mod} ->
mod.start()
{:error, _} ->
:ok
end
port = Application.get_env(:week_budget, :port, 2019)
# List all child processes to be supervised
children = [
{WeekBudget.DB.Repo, []},
{WeekBudget.API.Server, [[], [port: port]]}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: WeekBudget.Core.Supervisor]
Supervisor.start_link(children, opts)
end
end

3
backend/lib/core/cldr.ex Normal file
View file

@ -0,0 +1,3 @@
defmodule WeekBudget.Core.Cldr do
use Cldr, locales: ["en"], default_locale: "en"
end

View file

@ -0,0 +1,22 @@
defmodule WeekBudget.Core.MoneyUtils do
@moduledoc """
Utilities to do with money operations.
"""
@doc """
Create money from given integer or float and currency. Float is used to easier accept money from the
frontends. It is normalised to a Money instance at first opportunity so it is believed that the
impact and potential for errors is minimal.
"""
@spec create(atom | string, integer | float) ::
{:ok, Money.t()} | {:error, {module, String.t()}}
def create(currency, amount)
def create(currency, amount) when is_integer(amount) do
Money.new(currency, amount)
end
def create(currency, amount) when is_float(amount) do
Money.from_float(currency, amount)
end
end

43
backend/lib/db/budget.ex Normal file
View file

@ -0,0 +1,43 @@
defmodule WeekBudget.DB.Budget do
@moduledoc """
A single budget that has a secret token that can be used to join it and an initial sum.
The final sum can be calculated by subtracting all the events from the initial sum.
"""
use Ecto.Schema
import Ecto.Query, only: [from: 2]
@primary_key {:secret, :binary_id, autogenerate: true}
schema "budgets" do
field(:amount, Money.Ecto.Composite.Type)
timestamps()
has_many(:events, WeekBudget.DB.Event, foreign_key: :budget_id)
end
@doc """
Get budget by its secret, if any exists.
"""
@spec get_by_secret(String.t()) :: __MODULE__.t() | nil
def get_by_secret(secret) do
from(w in __MODULE__,
where: w.secret == ^secret,
select: [:secret, :amount],
preload: [:events]
)
|> WeekBudget.DB.Repo.one()
end
@doc """
Create budget with given initial amount.
"""
@spec create(Money.t()) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
def create(amount) do
%__MODULE__{
amount: amount
}
|> WeekBudget.DB.Repo.insert()
end
end

32
backend/lib/db/event.ex Normal file
View file

@ -0,0 +1,32 @@
defmodule WeekBudget.DB.Event do
@moduledoc """
A money spending event that occurred on the budget. If amount is positive, money was spent. If amount
is negative, money was received.
"""
use Ecto.Schema
schema "events" do
field(:amount, Money.Ecto.Composite.Type)
field(:at, :utc_datetime)
belongs_to(:budget, WeekBudget.DB.Budget,
references: :secret,
foreign_key: :budget_id,
type: :binary_id
)
end
@doc """
Create a new event with the given amount. Note that normally the amount should be negative.
"""
@spec create(WeekBudget.DB.Budget.t(), Money.t()) ::
{:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
def create(%WeekBudget.DB.Budget{} = budget, %Money{} = amount) do
%__MODULE__{
amount: amount,
budget: budget,
at: DateTime.utc_now() |> DateTime.truncate(:second)
}
|> WeekBudget.DB.Repo.insert()
end
end

9
backend/lib/db/repo.ex Normal file
View file

@ -0,0 +1,9 @@
defmodule WeekBudget.DB.Repo do
use Ecto.Repo,
otp_app: :week_budget,
adapter: Ecto.Adapters.Postgres
def init(_type, config) do
{:ok, Keyword.put(config, :url, Application.get_env(:week_budget, :database_url))}
end
end

37
backend/mix.exs Normal file
View file

@ -0,0 +1,37 @@
defmodule WeekBudget.MixProject do
use Mix.Project
def project do
[
app: :week_budget,
version: "0.1.0",
elixir: "~> 1.8",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
mod: {WeekBudget.Core.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:raxx, "~> 1.0.1"},
{:raxx_logger, "~> 0.2.2"},
{:ace, "~> 0.18.8"},
{:ecto, "~> 3.1.4"},
{:ecto_sql, "~> 3.1.3"},
{:jason, "~> 1.1.2"},
{:observer_cli, "~> 1.5.0"},
{:postgrex, ">= 0.0.0"},
{:ex_money, "~> 3.4.2"},
{:exsync, "~> 0.2.3", only: :dev}
]
end
end

26
backend/mix.lock Normal file
View file

@ -0,0 +1,26 @@
%{
"ace": {:hex, :ace, "0.18.8", "9853110247f769e7d1600be7cd2abd638b53391c8c7b1ab11baad15184cb95d4", [:mix], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}, {:raxx, "~> 0.17.0 or ~> 0.18.0 or ~> 1.0", [hex: :raxx, repo: "hexpm", optional: false]}], "hexpm"},
"cldr_utils": {:hex, :cldr_utils, "2.2.0", "4f6ac090fc1871037ac41265c98bf0f785b2a4ede144dd2a5282986a5311dd14", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"db_connection": {:hex, :db_connection, "2.0.6", "bde2f85d047969c5b5800cb8f4b3ed6316c8cb11487afedac4aa5f93fd39abfa", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
"decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "3.1.4", "69d852da7a9f04ede725855a35ede48d158ca11a404fe94f8b2fb3b2162cd3c9", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ecto_sql": {:hex, :ecto_sql, "3.1.3", "2c536139190492d9de33c5fefac7323c5eaaa82e1b9bf93482a14649042f7cd9", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"ex_cldr": {:hex, :ex_cldr, "2.7.0", "be0f1c88f1baeb8364395df7d3266a8d7f075ebaeb00d20db743b1d72fa47e55", [:mix], [{:cldr_utils, "~> 2.1", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.3.0", "bffae489416b8b05d4683403263f5d62aae17de70c24ff915a533541fea514de", [:mix], [{:ex_cldr, "~> 2.6", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.6.0", "431c744058a3980f561878f4886b78871d4067c6d9039ce78335ed9a41c831f8", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.6", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.3", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ex_money": {:hex, :ex_money, "3.4.2", "7da06947ed92fab7ad265f7fe1f6d36b30404f2b33c7b46de54670921dfe3420", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:ex_cldr, "~> 2.6", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.6", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm"},
"ex_sync": {:hex, :ex_sync, "0.0.4", "468839964dead69df738a3698e4318d18fa3322e4f684adf98d5441704d1bc8c", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"},
"exsync": {:hex, :exsync, "0.2.3", "a1ac11b4bd3808706003dbe587902101fcc1387d9fc55e8b10972f13a563dd15", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"},
"file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"},
"hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"},
"observer_cli": {:hex, :observer_cli, "1.5.0", "9944882b71f55b2503663d9cb54d3f1c7bbdf7cc6dd01cc40ea8ef51207601ec", [:mix, :rebar3], [{:recon, "2.5.0", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"raxx": {:hex, :raxx, "1.0.1", "8c51ec5227c85f999360fc844fc1d4e2e5a2adf2b0ce068eb56243ee6b2f65e3", [:mix], [], "hexpm"},
"raxx_logger": {:hex, :raxx_logger, "0.2.2", "51bbce9371298e7329de028fe13cf050bf59547bc8f1926910d479ba4b31c4b4", [:mix], [{:raxx, "~> 0.17.5 or ~> 0.18.0 or ~> 1.0", [hex: :raxx, repo: "hexpm", optional: false]}], "hexpm"},
"recon": {:hex, :recon, "2.5.0", "2f7fcbec2c35034bade2f9717f77059dc54eb4e929a3049ca7ba6775c0bd66cd", [:mix, :rebar3], [], "hexpm"},
"telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
}

View file

@ -0,0 +1,11 @@
defmodule WeekBudget.DB.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres do
use Ecto.Migration
def up do
execute("CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric);")
end
def down do
execute("DROP TYPE public.money_with_currency;")
end
end

View file

@ -0,0 +1,12 @@
defmodule WeekBudget.DB.Repo.Migrations.Init do
use Ecto.Migration
def change do
create table(:budgets, primary_key: false) do
add(:secret, :binary_id, primary_key: true)
add(:amount, :money_with_currency)
timestamps()
end
end
end

View file

@ -0,0 +1,18 @@
defmodule WeekBudget.DB.Repo.Migrations.AddEvents do
use Ecto.Migration
def change do
create table(:events) do
add(:amount, :money_with_currency, null: false)
add(:at, :utc_datetime, null: false)
add(
:budget,
references("budgets", column: :secret, type: :binary_id, on_delete: :delete_all),
null: false
)
end
create(index("events", :budget))
end
end

View file

@ -0,0 +1,7 @@
defmodule WeekBudget.DB.Repo.Migrations.BudgetFkeyName do
use Ecto.Migration
def change do
rename(table("events"), :budget, to: :budget_id)
end
end

View file

@ -0,0 +1 @@
ExUnit.start()

View file

@ -0,0 +1,8 @@
defmodule WeekBudgetTest do
use ExUnit.Case
doctest WeekBudget
test "greets the world" do
assert WeekBudget.hello() == :world
end
end