diff --git a/.gitattributes b/.gitattributes index 719619a..8038473 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ # Handle line endings automatically for files detected as text # and leave all files detected as binary untouched. -* text=auto +* text eol=lf # Elixir files *.ex diff=elixir diff --git a/.github/workflows/elixir.yaml b/.github/workflows/elixir.yaml index 4c3e71f..ecdcdca 100644 --- a/.github/workflows/elixir.yaml +++ b/.github/workflows/elixir.yaml @@ -68,6 +68,11 @@ jobs: name: Test runs-on: ${{ matrix.os }} steps: + - name: Set Git to use LF insted of CRLF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 diff --git a/README.md b/README.md index 0712959..76ea176 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # NumscriptEx +[![Hex Version](https://img.shields.io/hexpm/v/numscriptex.svg)](https://hex.pm/packages/numscriptex) +[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/numscriptex/) + NumscriptEx is a library that allows its users to check and run Numscripts in Elixir. If this is your first time hearing about Numscripts, here is a quick explanation: @@ -35,12 +38,12 @@ config :numscriptex, These above are the default values. ## Usage -This library basically has two core functions: `Numscriptex.check/1` and `Numscriptex.run/2`. +You can build Numscripts dynamically with `Numscriptex.Builder.build/1`, check if your script is valid with `Numscriptex.check/1`, +and last but not least, you can run your script with `Numscriptex.run/2`. -Want to check if your script is valid and ready to go? Use the `check/1` function. -Already checked the script and want to execute it? Use the `run/2` function. +You can read more about the `Numscriptex.Builder` module and how to use it on its [guide](https://github.com/PagoPlus/numscriptex/blob/main/guides/builder.md) -But before introducing these two functions, you will need to know what is the `Numscriptex.Run` struct. +And before introducing the other two functions, you will need to know what is the `Numscriptex.Run` struct. ### Numscriptex.Run A numscript needs some other data aside the script itself to run correctly, and @@ -184,4 +187,4 @@ iex> %{ ## License Copyright (c) 2025 MedFlow -This library is MIT licensed. See the [LICENSE](https://github.com/PagoPlus/numscriptex/blob/main/README.md) for details. +This library is MIT licensed. See the [LICENSE](https://github.com/PagoPlus/numscriptex/blob/main/LINCESE) for details. diff --git a/guides/builder.md b/guides/builder.md new file mode 100644 index 0000000..b03546e --- /dev/null +++ b/guides/builder.md @@ -0,0 +1,272 @@ +# Builder +This is a feature that makes it possible to build numscripts dynamically within your application. + +## Usage +You just need to call `Numscriptex.Builder.build/1` with the metadata necessary to build your numscript. + +### Metadata +Metadata is the sole argument you need to use the `build/1` function, wich has the following fields: + +Required fields: +- splits: a list of metadata needed to build numscripts (explained below) + +Optional fields: +- percent_asset: monetary asset (BRL, USD, EUR, and etc.) to use when build numscripts using percentage values. +- remaining_to: where the rest of the money will go after the transaction is made + - Only needed if you want to designate a specific destination. The default is [remaining kept](https://docs.formance.com/numscript/reference/destinations#allocation-destinations) wich makes the remainder go back to the source. + +About the "list of metadata" mentioned on the `split` field above: + +Required fields: +- account: account whose the money will go to +- amount: amount of money transferred. Be aware that this field only accepts integer values, so if you want to send $5 your amount field value should be 500. +- type: the amount type, accepts two values: + - fixed: treats the `amount` field as a number + - percent: treats the `amount` field as a percentage value + +Optional fields: +Also accepts `aplits` and `remaining_to` fields, and a `asset` field: + +- splits: in this case, this field is only neccessary if you want to build [nested destinations](https://docs.formance.com/numscript/reference/destinations#nested-destinations), and therefore it is only valid if the `type` field value is `:percent` +- asset: also the monetary asset, but in this case is only needed on `:fixed` types + +The `remaining_to` field continues with the same behaviour as above. + +### Example +With the metadata type above in mind, let's build a simple numscript. +```elixir +# metadata: +%{ + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ], + remaining_to: "another:destination", + percent_asset: "USD" +} +``` +Wich will generate the following numscript: +``` +send [USD *] ( + source = @user + destination = { + 20% to @some:destination + remaining to @another:destination + } +) +``` +If the `:remaining_to` field were not defined, the remaining would be kept: +``` +send [USD *] ( + source = @user + destination = { + 20% to @some:destination + remaining kept + } +) +``` + +P.S.: The percent amount types will always be executed first, so we can assure that +all of the percentages are based off of the initial value. This feature is still in +development as we speak, but soon we will have more flexibility in those regards. + +## Examples +### Example 1 +Both fixed and percent types: +```elixir +%{ + splits: [ + %{ + type: :fixed, + amount: 500, + asset: "USD", + account: "some:destination:a" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:b" + } + ], + remaining_to: "some:destination:c", + percent_asset: "USD" +} +``` +Will generate: +``` +send [USD *] ( + source = @user + destination = { + 20% to @some:destination:b + remaining kept + } +) + +send [USD 500] ( + source = @user + destination = @some:destination:a +) +``` + +### Example 2 +Nested percent-type, plus fixed +```elixir +%{ + splits: [ + %{ + type: :fixed, + amount: 1250, + asset: "USD", + account: "some:destination:a" + }, + %{ + type: :percent, + amount: 30, + remaining_to: "remaining:destination:b", + splits: [ + %{ + type: :percent, + amount: 40, + account: "some:destination:c" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:b" + } + ] + } + ], + remaining_to: "remaining:destination:a", + percent_asset: "USD" +} +``` +Will generate: +``` +send [USD *] ( + source = @user + destination = { + 30% to { + 40% to @some:destination:c + 20% to @some:destination:b + remaining to remaining:destination:b + } + remaining to remaining:destination:a + } +) + +send [USD 1250] ( + source = @user + destination = @some:destination:a +) +``` +Note that the `asset` field inside of nested destinations will be ignored, given that +they all go under the same `send` scope. + +### Example 3 +Nested percent-type, plus fixed, and with fixed-type within nests +```elixir +%{ + splits: [ + %{ + type: :fixed, + amount: 1050, + asset: "EUR", + account: "some:destination:a" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:b" + }, + %{ + type: :percent, + amount: 30, + remaining_to: "remaining:destination:b", + splits: [ + %{ + type: :fixed, + amount: 1250, + asset: "USD", + account: "some:destination:c" + }, + %{ + type: :percent, + amount: 15, + account: "some:destination:e" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:d" + }, + %{ + type: :percent, + amount: 50, + remaining_to: "remaining:destination:c", + splits: [ + %{ + type: :fixed, + amount: 500, + asset: "BRL", + account: "some:destination:b" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:a" + }, + %{ + type: :percent, + amount: 27, + account: "some:destination:a" + } + ] + } + ] + } + ], + remaining_to: "remaining:destination:a", + percent_asset: "EUR" +} +``` +Will generate: +``` +send [EUR *] ( + source = @user + destination = { + 20% to @some:destination:b + 30% to { + 15% to @some:destination:e + 20% to @some:destination:d + 50% to { + 20% to @some:destination:a + 27% to @some:destination:a + remaining to @remaining:destination:c + } + remaining to @remaining:destination:b + } + remaining to @remaining:destination:a + } +) + +send [USD 1250] ( + source = @user + destination = @some:destination:c +) + +send [BRL 500] ( + source = @user + destination = @some:destination:b +) + +send [EUR 1050] ( + source = @user + destination = @some:destination:a +) +``` +Note that the `fixed` types in nested `splits` (i.e. `splits` fields inside of `splits` fields) will be popped out and still +be built after the `percent` types. diff --git a/lib/numscriptex/builder.ex b/lib/numscriptex/builder.ex new file mode 100644 index 0000000..28cb771 --- /dev/null +++ b/lib/numscriptex/builder.ex @@ -0,0 +1,194 @@ +defmodule Numscriptex.Builder do + @moduledoc """ + `Numscriptex.Builder` makes it possible to build Numscripts dynamically within your application. + """ + + @type percent_split() :: %{ + required(:type) => :fixed | :percent, + required(:amount) => pos_integer(), + optional(:account) => bitstring(), + optional(:splits) => list(percent_split()), + optional(:remaining_to) => bitstring() + } + + @type fixed_split() :: %{ + required(:type) => :fixed | :percent, + required(:amount) => pos_integer(), + required(:account) => bitstring(), + required(:asset) => bitstring() + } + + @type metadata() :: %{ + required(:splits) => list(fixed_split()) | list(percent_split()), + optional(:remaining_to) => bitstring(), + optional(:percent_asset) => bitstring() + } + + @doc """ + Receives a map with the metadata necessary to build your numscript. + + ```elixir + iex> metadata = %{ + ...> splits: [ + ...> %{ + ...> type: :fixed, + ...> amount: 500, + ...> asset: "BRL/2", + ...> account: "some:destination" + ...> } + ...> ] + ...> } + ...> + ...> Numscriptex.Builder.build(metadata) + {:ok, %{script: "send [BRL/2 500] (\n source = @user\n destination = @some:destination\n)\n"}} + ``` + + If you want to learn more about this feature you can check its guide [here](https://github.com/PagoPlus/numscriptex/blob/main/guides/builder.md) + """ + @spec build(metadata()) :: {:ok, %{script: bitstring()}} | {:error, bitstring()} + def build(%{splits: _splits} = metadata) do + with {:ok, {port_splits, fixed_splits}} <- check_metadata(metadata) do + remaining_dest = metadata[:remaining_to] + asset = metadata[:percent_asset] + port_numscript = maybe_build_portioned_numscript(port_splits, remaining_dest, asset) + fixed_numscript = maybe_build_fixed_values_numscript(fixed_splits) + + {:ok, %{script: port_numscript <> fixed_numscript}} + end + end + + def build(_metadata), do: {:error, "Missing key on metadata."} + + defp check_metadata(%{splits: splits} = metadata) do + percent_scripts = Enum.filter(splits, &(&1.type == :percent)) + has_percent? = percent_scripts != [] + has_percent_asset? = :percent_asset in Map.keys(metadata) + + fixed_scripts = filter_fixed_splits(splits) + has_fixed? = fixed_scripts != [] + + has_fixed_assets? = + Enum.all?(fixed_scripts, fn + %{asset: _asset} -> true + _ -> false + end) + + cond do + has_percent? and has_percent_asset? and has_fixed_assets? -> + {:ok, {percent_scripts, fixed_scripts}} + + has_fixed? and has_fixed_assets? -> + {:ok, {percent_scripts, fixed_scripts}} + + true -> + {:error, "Missing key on metadata."} + end + end + + defp filter_fixed_splits(splits, acc \\ []) + + defp filter_fixed_splits([%{type: :fixed} = split | tail], acc), + do: filter_fixed_splits(tail, [split | acc]) + + defp filter_fixed_splits([%{type: :percent, splits: splits} | _tail], acc), + do: filter_fixed_splits(splits, acc) + + defp filter_fixed_splits([_split | tail], acc), do: filter_fixed_splits(tail, acc) + defp filter_fixed_splits([], acc), do: acc + + defp maybe_build_portioned_numscript([], _remaining_dest, _asset), do: "" + + defp maybe_build_portioned_numscript(metadata, remaining_dest, asset) do + initial = start_numscript(asset) + + metadata + |> Enum.reduce(initial, fn data, acc -> acc <> build_numscript(data) end) + |> close_numscript(remaining_dest) + end + + defp maybe_build_fixed_values_numscript([]), do: "" + + defp maybe_build_fixed_values_numscript(metadata) do + Enum.reduce(metadata, "", fn data, acc -> + acc <> build_numscript(data) + end) + end + + defp build_numscript(%{type: :percent, splits: [_ | _] = splits} = metadata) do + initial = start_portioned_dest(metadata.amount) + + splits + |> Enum.reduce(initial, fn + %{type: :percent} = split, acc -> + acc <> build_numscript(split) + + _split, acc -> + acc + end) + |> close_portioned_dest(metadata[:remaining_to]) + end + + defp build_numscript(%{amount: amount, account: dest, type: type, asset: asset}) + when type == :fixed do + """ + send [#{asset} #{amount}] ( + source = @user + destination = @#{dest} + ) + """ + end + + defp build_numscript(%{amount: amount, account: dest, type: type}) when type == :percent do + """ + #{amount}% to @#{dest} + """ + end + + defp start_numscript(asset) do + """ + send [#{asset} *] ( + source = @user + destination = { + """ + end + + defp start_portioned_dest(amount) do + """ + #{amount}% to { + """ + end + + defp close_numscript(script, nil) do + script <> + """ + remaining kept + } + ) + """ + end + + defp close_numscript(script, remaining_dest) do + script <> + """ + remaining to @#{remaining_dest} + } + ) + """ + end + + defp close_portioned_dest(script, nil) do + script <> + """ + remaining kept + } + """ + end + + defp close_portioned_dest(script, remaining_dest) do + script <> + """ + remaining to @#{remaining_dest} + } + """ + end +end diff --git a/lib/numscriptex/posting.ex b/lib/numscriptex/posting.ex index b9900ed..2b682f6 100644 --- a/lib/numscriptex/posting.ex +++ b/lib/numscriptex/posting.ex @@ -16,7 +16,7 @@ defmodule Numscriptex.Posting do * `:source` account whose the money came from * `:asset` the asset were the transaction was made * `:destination` account whose the money will go to - * `:amount` amount of mone transferred (in integer) + * `:amount` amount of money transferred (integer) """ @type t() :: %__MODULE__{ amount: pos_integer(), diff --git a/mix.exs b/mix.exs index a2ed793..08970ba 100644 --- a/mix.exs +++ b/mix.exs @@ -56,9 +56,29 @@ defmodule Numscriptex.MixProject do [ main: "readme", name: "NumscriptEx", + extra_section: "guides", suorce_ref: "v#{@version}", source_url: @source_url, - extras: ["README.md"] + extras: extras(), + groups_for_extras: groups_for_extras() + ] + end + + defp extras do + [ + "README.md": [ + title: "Readme" + ], + "guides/builder.md": [ + title: "Building Numscripts", + filename: "builder-introduction" + ] + ] + end + + defp groups_for_extras() do + [ + Tutorial: Path.wildcard("guides/*.md") ] end diff --git a/test/numscriptex/builder_test.exs b/test/numscriptex/builder_test.exs new file mode 100644 index 0000000..d284c4e --- /dev/null +++ b/test/numscriptex/builder_test.exs @@ -0,0 +1,197 @@ +defmodule Numscriptex.BuilderTest do + use ExUnit.Case + + alias Numscriptex.Builder + + doctest Numscriptex.Builder + + describe "build/1" do + test "simple send with fixed value" do + metadata = %{ + splits: [ + %{ + type: :fixed, + amount: 500, + asset: "USD", + account: "some:destination" + } + ] + } + + assert {:ok, %{script: script}} = Builder.build(metadata) + assert {:ok, _script} = Numscriptex.check(script) + + assert script == """ + send [USD 500] ( + source = @user + destination = @some:destination + ) + """ + end + + test "simple send with percentage value" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 20, + asset: "USD", + account: "some:destination" + } + ], + percent_asset: "USD" + } + + assert {:ok, %{script: script}} = Builder.build(metadata) + assert {:ok, _script} = Numscriptex.check(script) + + assert script == """ + send [USD *] ( + source = @user + destination = { + 20% to @some:destination + remaining kept + } + ) + """ + end + + test "multiple send with both fixed and percentage value with 'remaining_to' opt" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination:a" + }, + %{ + type: :fixed, + amount: 1000, + asset: "USD", + account: "some:destination:b" + } + ], + percent_asset: "USD", + remaining_to: "another:destination" + } + + assert {:ok, %{script: script}} = Builder.build(metadata) + assert {:ok, _script} = Numscriptex.check(script) + + assert script == """ + send [USD *] ( + source = @user + destination = { + 20% to @some:destination:a + remaining to @another:destination + } + ) + send [USD 1000] ( + source = @user + destination = @some:destination:b + ) + """ + end + + test "multiple send with nested destinations and fixed values and 'remaining_to' opt" do + metadata = %{ + splits: [ + %{ + type: :fixed, + amount: 1050, + asset: "EUR", + account: "some:destination:a" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:b" + }, + %{ + type: :percent, + amount: 30, + remaining_to: "remaining:destination:b", + splits: [ + %{ + type: :fixed, + amount: 1250, + asset: "USD", + account: "some:destination:c" + }, + %{ + type: :percent, + amount: 15, + account: "some:destination:e" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:d" + }, + %{ + type: :percent, + amount: 50, + remaining_to: "remaining:destination:c", + splits: [ + %{ + type: :fixed, + amount: 500, + asset: "BRL", + account: "some:destination:b" + }, + %{ + type: :percent, + amount: 20, + account: "some:destination:a" + }, + %{ + type: :percent, + amount: 27, + account: "some:destination:a" + } + ] + } + ] + } + ], + remaining_to: "remaining:destination:a", + percent_asset: "EUR" + } + + assert {:ok, %{script: script}} = Builder.build(metadata) + assert {:ok, _script} = Numscriptex.check(script) + + assert script == """ + send [EUR *] ( + source = @user + destination = { + 20% to @some:destination:b + 30% to { + 15% to @some:destination:e + 20% to @some:destination:d + 50% to { + 20% to @some:destination:a + 27% to @some:destination:a + remaining to @remaining:destination:c + } + remaining to @remaining:destination:b + } + remaining to @remaining:destination:a + } + ) + send [BRL 500] ( + source = @user + destination = @some:destination:b + ) + send [USD 1250] ( + source = @user + destination = @some:destination:c + ) + send [EUR 1050] ( + source = @user + destination = @some:destination:a + ) + """ + end + end +end