diff --git a/.formatter.exs b/.formatter.exs index ef8840c..011a477 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ - import_deps: [:ecto, :ecto_sql, :phoenix], + import_deps: [:ecto, :ecto_sql, :phoenix, :stream_data], subdirectories: ["priv/*/migrations"], plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] diff --git a/lib/monfari/types/amount.ex b/lib/monfari/types/amount.ex new file mode 100644 index 0000000..05032e5 --- /dev/null +++ b/lib/monfari/types/amount.ex @@ -0,0 +1,48 @@ +defmodule Monfari.Types.Amount do + use Ecto.Type + def type, do: :string + + defstruct [:sign, :whole, :part, :currency] + + @re ~r/^(-?)([0-9]+)\.([0-9]{2}) ([A-Z]{3})$/ + + def cast(str) when is_binary(str) do + case Regex.run(@re, str, capture: :all_but_first) do + [sign, whole, part, currency] -> + {:ok, + %__MODULE__{ + sign: %{"" => 1, "-" => -1}[sign], + whole: String.to_integer(whole), + part: String.to_integer(part), + currency: currency + }} + + nil -> + :error + end + end + + def cast(%__MODULE__{} = val), do: val + + def load(str) when is_binary(str), do: cast(str) + + def dump(%__MODULE__{sign: sign, whole: whole, part: part, currency: currency}) do + sign = %{-1 => "-", 1 => ""}[sign] + {:ok, "#{sign}#{whole}.#{part} #{currency}"} + end + + def dump(_), do: :error + + def value(%__MODULE__{sign: sign, whole: whole, part: part}), do: sign * (whole * 100 + part) + + def from_value(value, currency) do + sign = if value >= 0 do 1 else -1 end + whole = div(abs(value), 100) + part = rem(abs(value), 100) + %__MODULE__{sign: sign, whole: whole, part: part, currency: currency} + end + + def add(%__MODULE__{currency: currency} = lhs, %__MODULE__{currency: currency} = rhs) do + from_value(value(lhs) + value(rhs), currency) + end +end diff --git a/lib/monfari/types/transaction.ex b/lib/monfari/types/transaction.ex new file mode 100644 index 0000000..8f0404f --- /dev/null +++ b/lib/monfari/types/transaction.ex @@ -0,0 +1,32 @@ +defmodule Monfari.Schema.Transaction do + use Ecto.Schema + + defmodule Effects do + use Ecto.Type + + alias Monfari.Types.Amount + + def type, do: {:map, Amount} + + def cast(%{} = s), do: s |> Enum.map(fn {key, value} -> + + end) |> Enum.into(%{}) + + end + + @primary_key {:id, Needle.ULID, autogenerate: true} + @foreign_key_type Needle.ULID + @derive Jason.Encoder + + schema "transactions" do + field :notes, :string + field :effects, Effects + end + + defmodule Commands.Create do + end + defmodule Commands.Update do + end + defmodule Commands.Delete do + end +end diff --git a/mix.exs b/mix.exs index 656e7ae..d7d7e28 100644 --- a/mix.exs +++ b/mix.exs @@ -58,7 +58,11 @@ defmodule Monfari.MixProject do {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:proquint, "~> 1.0.0"}, + {:needle_ulid, "~> 0.3.0"}, + + {:stream_data, "~> 1.0", only: [:test, :dev]} ] end diff --git a/mix.lock b/mix.lock index 2664994..058c519 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.3", "ec6e5699646c923bc4a7d4d60f4aa6433345a53863561e0e91e7e9d2a6bf7577", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "62434777e2e9b6675e37f4f5eaf985b63f55fe424266289ca72b35734635bf9e"}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, + "ex_ulid": {:hex, :ex_ulid, "0.1.0", "e6e717c57344f6e500d0190ccb4edc862b985a3680f15834af992ec065d4dcff", [:mix], [], "hexpm", "a2befd477aebc4639563de7e233e175cacf8a8f42c8f6778c88d60c13bf20860"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "exqlite": {:hex, :exqlite, "0.26.0", "8c6118cdd36482b0081d1ca7c3f8db514eb4c0765f853936ef096757165cedf3", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "e4a5e0c8309e3a3981f6803aa7073bc5777559fe5bc367543c30c7b95f0aed28"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, @@ -21,6 +22,7 @@ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "needle_ulid": {:hex, :needle_ulid, "0.3.0", "7ecccec131c5218ec7cf5f8a236c016f436e78dfd684204de9f66c40271e7e04", [:mix], [{:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.8", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:ex_ulid, "~> 0.1", [hex: :ex_ulid, repo: "hexpm", optional: false]}], "hexpm", "d54d0dc5267b3ba3a7f981060b329bc8fb1f55104d790e54d808c8a5f4cb3e93"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, @@ -33,6 +35,8 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "proquint": {:hex, :proquint, "1.0.2", "b308f81c33fb89a537ca73ef5f76ae5f5514802f75058294fbcd6405260122a5", [:mix], [], "hexpm", "2bc6fd38ea67e6201b39f55204a184af3ca98765940374f8eaadf10bccc78ff3"}, + "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, "swoosh": {:hex, :swoosh, "1.17.2", "73611f08fc7cb9fa15f4909db36eeb12b70727d5c8b6a7fa0d4a31c6575db29e", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de914359f0ddc134dc0d7735e28922d49d0503f31e4bd66b44e26039c2226d39"}, "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, diff --git a/priv/repo/migrations/20241108122905_init.exs b/priv/repo/migrations/20241108122905_init.exs new file mode 100644 index 0000000..1d00e2c --- /dev/null +++ b/priv/repo/migrations/20241108122905_init.exs @@ -0,0 +1,7 @@ +defmodule Monfari.Repo.Migrations.Init do + use Ecto.Migration + + def change do + + end +end diff --git a/test/monfari/amount_test.exs b/test/monfari/amount_test.exs new file mode 100644 index 0000000..84e7169 --- /dev/null +++ b/test/monfari/amount_test.exs @@ -0,0 +1,33 @@ +defmodule Monfari.AmountTest do + use ExUnit.Case + use ExUnitProperties + alias Monfari.Types.Amount + + defp amounts(), do: member_of(["USD", "GBP", "EUR"]) |> bind(&amounts/1) + + defp amounts(currency) do + gen all sign <- member_of([1, -1]), + whole <- non_negative_integer(), + part <- integer(0..99) do + %Amount{sign: sign, whole: whole, part: part, currency: currency} + end + end + + property "parser and formatter roundtrip" do + check all amount <- amounts() do + with {:ok, str} <- Amount.dump(amount), + {:ok, roundtripped} <- Amount.load(str), + do: assert roundtripped == amount + end + end + + property "value roundtrips" do + check all value <- integer() do + assert Amount.value(Amount.from_value(value, "USD")) == value + end + + check all amount <- amounts() do + assert Amount.from_value(Amount.value(amount), amount.currency) == amount + end + end +end