diff --git a/README.md b/README.md index 16ca9bb..d8ca99b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ These above are the default values. 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`. -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) +You can read more about the `Numscriptex.Builder` module and how to use it on its [guide](https://hexdocs.pm/numscriptex/builder-introduction.html) And before introducing the other two functions, you will need to know what is the `Numscriptex.Run` struct. @@ -194,4 +194,4 @@ iex> %{ ## License Copyright (c) 2025 MedFlow -This library is MIT licensed. See the [LICENSE](https://github.com/PagoPlus/numscriptex/blob/main/LINCESE) for details. +This library is MIT licensed. See the [LICENSE](https://github.com/PagoPlus/numscriptex/blob/main/LICENSE) for details. diff --git a/lib/numscriptex/builder.ex b/lib/numscriptex/builder.ex index 28cb771..89f9221 100644 --- a/lib/numscriptex/builder.ex +++ b/lib/numscriptex/builder.ex @@ -43,17 +43,19 @@ defmodule Numscriptex.Builder do {: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) + If you want to learn more about this feature you can check its guide [here](https://hexdocs.pm/numscriptex/builder-introduction.html) """ @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}} + with {:ok, port_numscript} <- + maybe_build_portioned_numscript(port_splits, remaining_dest, asset), + {:ok, fixed_numscript} <- maybe_build_fixed_values_numscript(fixed_splits) do + {:ok, %{script: port_numscript <> fixed_numscript}} + end end end @@ -96,86 +98,157 @@ defmodule Numscriptex.Builder do 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([], _remaining_dest, _asset), do: {:ok, ""} 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) + with {:ok, initial} <- start_numscript(asset) do + metadata + |> Enum.reduce_while(initial, fn data, acc -> handle_build(data, acc) end) + |> close_numscript(remaining_dest) + end end - defp maybe_build_fixed_values_numscript([]), do: "" + defp maybe_build_fixed_values_numscript([]), do: {:ok, ""} defp maybe_build_fixed_values_numscript(metadata) do - Enum.reduce(metadata, "", fn data, acc -> - acc <> build_numscript(data) - end) + metadata + |> Enum.reduce_while("", fn data, acc -> handle_build(data, acc) end) + |> case do + {:error, _reason} = error -> + error + + script -> + {:ok, script} + end end defp build_numscript(%{type: :percent, splits: [_ | _] = splits} = metadata) do - initial = start_portioned_dest(metadata.amount) + initial = start_portioned_dest(metadata[:amount]) splits - |> Enum.reduce(initial, fn + |> Enum.reduce_while(initial, fn %{type: :percent} = split, acc -> - acc <> build_numscript(split) + handle_build(split, acc) _split, acc -> - acc + {:cont, 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} - ) - """ + when type == :fixed and + is_integer(amount) and + amount > 0 and + is_binary(dest) and + is_binary(asset) do + case {String.trim(asset), String.trim(dest)} do + {_, ""} -> + {:error, "Invalid destination."} + + {"", _} -> + {:error, "Invalid asset."} + + {asset, dest} -> + """ + send [#{asset} #{amount}] ( + source = @user + destination = @#{dest} + ) + """ + end end - defp build_numscript(%{amount: amount, account: dest, type: type}) when type == :percent do - """ - #{amount}% to @#{dest} - """ + defp build_numscript(%{amount: amount, account: dest, type: type}) + when type == :percent and + is_integer(amount) and + amount > 0 and + amount <= 100 and + is_binary(dest) do + case String.trim(dest) do + "" -> + {:error, "Invalid destination."} + + dest -> + """ + #{amount}% to @#{dest} + """ + end end - defp start_numscript(asset) do - """ - send [#{asset} *] ( - source = @user - destination = { - """ + defp build_numscript(_metadata), do: {:error, "Invalid metadata."} + + defp handle_build(split, acc) do + case build_numscript(split) do + {:error, _reason} = error -> + {:halt, error} + + script -> + {:cont, acc <> script} + end end - defp start_portioned_dest(amount) do + defp start_numscript(asset) when is_binary(asset) do + case String.trim(asset) do + "" -> + {:error, "Invalid asset."} + + asset -> + {:ok, + """ + send [#{asset} *] ( + source = @user + destination = { + """} + end + end + + defp start_numscript(_asset), do: {:error, "Invalid asset."} + + defp start_portioned_dest(amount) when is_integer(amount) and amount > 0 and amount <= 100 do """ #{amount}% to { """ end + defp start_portioned_dest(_amount), do: {:error, "Invalid amount."} + + defp close_numscript({:error, _reason} = error, _remaining_dest), do: error + defp close_numscript(script, nil) do - script <> - """ - remaining kept - } - ) - """ + script = + script <> + """ + remaining kept + } + ) + """ + + {:ok, script} end - defp close_numscript(script, remaining_dest) do - script <> - """ - remaining to @#{remaining_dest} - } - ) - """ + defp close_numscript(script, remaining_dest) when is_binary(remaining_dest) do + case String.trim(remaining_dest) do + "" -> + {:error, "Invalid remaining destination."} + + remaining_dest -> + script = + script <> + """ + remaining to @#{remaining_dest} + } + ) + """ + + {:ok, script} + end end + defp close_numscript(_script, _remaining_dest), do: {:error, "Invalid remaining destination."} + + defp close_portioned_dest({:error, _reason} = error, _remaining_dest), do: error + defp close_portioned_dest(script, nil) do script <> """ @@ -184,11 +257,20 @@ defmodule Numscriptex.Builder do """ end - defp close_portioned_dest(script, remaining_dest) do - script <> - """ - remaining to @#{remaining_dest} - } - """ + defp close_portioned_dest(script, remaining_dest) when is_binary(remaining_dest) do + case String.trim(remaining_dest) do + "" -> + {:error, "Invalid remaining destination."} + + remaining_dest -> + script <> + """ + remaining to @#{remaining_dest} + } + """ + end end + + defp close_portioned_dest(_script, _remaining_dest), + do: {:error, "Invalid remaining destination."} end diff --git a/test/numscriptex/builder_test.exs b/test/numscriptex/builder_test.exs index d284c4e..20f0605 100644 --- a/test/numscriptex/builder_test.exs +++ b/test/numscriptex/builder_test.exs @@ -193,5 +193,339 @@ defmodule Numscriptex.BuilderTest do ) """ end + + test "ignores account if split has nested splits" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 50, + account: "ignored:account", + remaining_to: "remaining:destination", + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ] + } + ], + percent_asset: "EUR", + remaining_to: "remaining:destination:a" + } + + assert {:ok, %{script: script}} = Builder.build(metadata) + assert {:ok, _script} = Numscriptex.check(script) + + assert script == """ + send [EUR *] ( + source = @user + destination = { + 50% to { + 20% to @some:destination + remaining to @remaining:destination + } + remaining to @remaining:destination:a + } + ) + """ + end + + test "ignores splits when is empty" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 50, + account: "ignored:account", + remaining_to: "remaining:destination", + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination", + splits: [] + } + ] + } + ], + percent_asset: "EUR", + remaining_to: "remaining:destination:a" + } + + assert {:ok, %{script: script}} = Builder.build(metadata) + assert {:ok, _script} = Numscriptex.check(script) + + assert script == """ + send [EUR *] ( + source = @user + destination = { + 50% to { + 20% to @some:destination + remaining to @remaining:destination + } + remaining to @remaining:destination:a + } + ) + """ + end + + test "if missing remaining_to, default to 'remaining kept'" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 50, + # missing remaining_to + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ] + } + ], + percent_asset: "EUR" + # missing remaining_to + } + + assert {:ok, %{script: script}} = Builder.build(metadata) + assert {:ok, _script} = Numscriptex.check(script) + + assert script == """ + send [EUR *] ( + source = @user + destination = { + 50% to { + 20% to @some:destination + remaining kept + } + remaining kept + } + ) + """ + end + + test "returns error when split type is invalid" do + metadata = %{ + splits: [ + %{ + type: :invalid, + amount: 20, + account: "some:destination" + } + ], + remaining_to: "remaining:destination", + percent_asset: "EUR" + } + + assert {:error, "Missing key on metadata."} = Builder.build(metadata) + end + + test "returns error when splits key is missing" do + metadata = %{ + remaining_to: "remaining:destination", + percent_asset: "EUR" + } + + assert {:error, "Missing key on metadata."} = Builder.build(metadata) + end + + test "returns error when remaining_to is not a string" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ], + remaining_to: 123, + percent_asset: "EUR" + } + + assert {:error, "Invalid remaining destination."} = Builder.build(metadata) + end + + test "returns error when percent_asset is not a string" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ], + percent_asset: 123 + } + + assert {:error, "Invalid asset."} = Builder.build(metadata) + end + + test "returns error when percent_asset is empty" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ], + percent_asset: "" + } + + assert {:error, "Invalid asset."} = Builder.build(metadata) + end + + test "returns error when percent splits exist but percent_asset is missing" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ] + } + + assert {:error, "Missing key on metadata."} = Builder.build(metadata) + end + + test "returns error when remaining_to is empty" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 20, + account: "some:destination" + } + ], + remaining_to: "", + percent_asset: "EUR" + } + + assert {:error, "Invalid remaining destination."} = Builder.build(metadata) + end + + test "returns error when percent amount is negative" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: -20, + account: "some:destination" + } + ], + percent_asset: "EUR" + } + + assert {:error, "Invalid metadata."} = Builder.build(metadata) + end + + test "returns error when percent amount is greater than 100" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 120, + account: "some:destination" + } + ], + percent_asset: "EUR" + } + + assert {:error, "Invalid metadata."} = Builder.build(metadata) + end + + test "returns error when percent split has invalid amount" do + metadata = %{ + splits: [ + %{ + type: :percent, + amount: 101, + account: "some:destination" + } + ], + percent_asset: "EUR" + } + + assert {:error, "Invalid metadata."} = Builder.build(metadata) + end + + test "returns error when fixed split is missing asset" do + metadata = %{ + splits: [ + %{ + type: :fixed, + amount: 500, + account: "some:destination" + } + ] + } + + assert {:error, "Missing key on metadata."} = Builder.build(metadata) + end + + test "returns error when fixed split has empty destination" do + metadata = %{ + splits: [ + %{ + type: :fixed, + amount: 500, + asset: "USD", + account: "" + } + ] + } + + assert {:error, "Invalid destination."} = Builder.build(metadata) + end + + test "returns error when fixed split has empty asset" do + metadata = %{ + splits: [ + %{ + type: :fixed, + amount: 500, + asset: "", + account: "some:destination" + } + ] + } + + assert {:error, "Invalid asset."} = Builder.build(metadata) + end + + test "returns error when fixed split has negative amount" do + metadata = %{ + splits: [ + %{ + type: :fixed, + amount: -500, + asset: "USD", + account: "some:destination" + } + ] + } + + assert {:error, "Invalid metadata."} = Builder.build(metadata) + end + + test "returns error when fixed split amount is not a number" do + metadata = %{ + splits: [ + %{ + type: :fixed, + amount: "500", + asset: "USD", + account: "some:destination" + } + ] + } + + assert {:error, "Invalid metadata."} = Builder.build(metadata) + end end end