diff --git a/lib/structs/semantic_tags/currency_amount.ex b/lib/structs/semantic_tags/currency_amount.ex new file mode 100644 index 0000000..732eccb --- /dev/null +++ b/lib/structs/semantic_tags/currency_amount.ex @@ -0,0 +1,105 @@ +defmodule ExPass.Structs.SemanticTags.CurrencyAmount do + @moduledoc """ + Represents a currency amount for use in Apple Wallet pass semantic tags. + + This struct is used to represent monetary values with their associated currency, + commonly used in semantic tags for balance, price, or other financial information. + + ## Fields + + * `:amount` - (Required) The amount of money as a string. + * `:currency_code` - (Required) The ISO 4217 currency code for the amount (e.g., "USD", "EUR"). + + ## Compatibility + + - iOS 12.0+ + - iPadOS 6.0+ + - watchOS 2.0+ + + ## Apple Documentation + + See [Apple's PassKit documentation](https://developer.apple.com/documentation/walletpasses/semantictagtype/currencyamount) + for more information about currency amounts in passes. + """ + + use ExPass.Structs.Base + use TypedStruct + + alias ExPass.Utils.Converter + alias ExPass.Utils.Validators + + typedstruct do + field :amount, String.t(), enforce: true + field :currency_code, String.t(), enforce: true + end + + @doc """ + Creates a new CurrencyAmount struct. + + ## Parameters + + * `attrs` - A map of attributes for the CurrencyAmount struct. The map must include: + * `:amount` - (Required) The amount of money as a string. + * `:currency_code` - (Required) The ISO 4217 currency code (e.g., "USD", "EUR", "GBP"). + + ## Returns + + * A new `%CurrencyAmount{}` struct. + + ## Examples + + iex> CurrencyAmount.new(%{amount: "10.00", currency_code: "USD"}) + %CurrencyAmount{amount: "10.00", currency_code: "USD"} + + iex> CurrencyAmount.new(%{amount: "25.50", currency_code: :EUR}) + %CurrencyAmount{amount: "25.50", currency_code: "EUR"} + + iex> CurrencyAmount.new(%{amount: "100", currency_code: "JPY"}) + %CurrencyAmount{amount: "100", currency_code: "JPY"} + + iex> CurrencyAmount.new(%{currency_code: "USD"}) + ** (ArgumentError) amount is a required field and must be a non-empty string + + iex> CurrencyAmount.new(%{amount: "10.00"}) + ** (ArgumentError) currency_code is required + + iex> CurrencyAmount.new(%{amount: "10.00", currency_code: "INVALID"}) + ** (ArgumentError) Invalid currency code INVALID + + """ + @spec new(map()) :: %__MODULE__{} + def new(attrs \\ %{}) do + attrs = + attrs + |> Converter.trim_string_values() + |> normalize_currency_code() + |> validate(:amount, &Validators.validate_required_string(&1, :amount)) + |> validate(:currency_code, &validate_required_currency_code/1) + + struct!(__MODULE__, attrs) + end + + # Normalize currency_code from atom to string if provided as atom + defp normalize_currency_code(attrs) do + case Map.get(attrs, :currency_code) do + code when is_atom(code) and not is_nil(code) -> + Map.put(attrs, :currency_code, Atom.to_string(code)) + + _ -> + attrs + end + end + + # Validates that currency_code is present and valid + defp validate_required_currency_code(nil) do + {:error, "currency_code is required"} + end + + defp validate_required_currency_code("") do + {:error, "currency_code is required"} + end + + defp validate_required_currency_code(value) do + Validators.validate_currency_code(value) + end +end diff --git a/test/structs/semantic_tags/currency_amount_test.exs b/test/structs/semantic_tags/currency_amount_test.exs new file mode 100644 index 0000000..41618d4 --- /dev/null +++ b/test/structs/semantic_tags/currency_amount_test.exs @@ -0,0 +1,162 @@ +defmodule ExPass.Structs.SemanticTags.CurrencyAmountTest do + @moduledoc false + + use ExUnit.Case, async: true + alias ExPass.Structs.SemanticTags.CurrencyAmount + + doctest ExPass.Structs.SemanticTags.CurrencyAmount + + describe "new/0" do + test "raises an error when called without arguments" do + assert_raise ArgumentError, + "amount is a required field and must be a non-empty string", + fn -> + CurrencyAmount.new() + end + end + end + + describe "new/1 with required fields" do + test "creates a valid CurrencyAmount struct with required fields" do + params = %{ + amount: "10.00", + currency_code: "USD" + } + + currency_amount = CurrencyAmount.new(params) + + assert %CurrencyAmount{ + amount: "10.00", + currency_code: "USD" + } = currency_amount + + encoded = Jason.encode!(currency_amount) + assert encoded =~ ~s("amount":"10.00") + assert encoded =~ ~s("currencyCode":"USD") + end + + test "raises an error for missing amount" do + assert_raise ArgumentError, + "amount is a required field and must be a non-empty string", + fn -> + CurrencyAmount.new(%{currency_code: "USD"}) + end + end + + test "raises an error for missing currency_code" do + assert_raise ArgumentError, "currency_code is required", fn -> + CurrencyAmount.new(%{amount: "10.00"}) + end + end + end + + describe "new/1 with amount field" do + test "accepts valid amount strings" do + assert %CurrencyAmount{amount: "10.00"} = + CurrencyAmount.new(%{amount: "10.00", currency_code: "USD"}) + + assert %CurrencyAmount{amount: "0"} = + CurrencyAmount.new(%{amount: "0", currency_code: "USD"}) + + assert %CurrencyAmount{amount: "1000000.99"} = + CurrencyAmount.new(%{amount: "1000000.99", currency_code: "USD"}) + end + + test "trims whitespace from amount" do + currency_amount = CurrencyAmount.new(%{amount: " 10.00 ", currency_code: "USD"}) + assert currency_amount.amount == "10.00" + end + + test "raises an error for empty amount" do + assert_raise ArgumentError, + "amount is a required field and must be a non-empty string", + fn -> + CurrencyAmount.new(%{amount: "", currency_code: "USD"}) + end + end + + test "raises an error for non-string amount" do + assert_raise ArgumentError, + "amount is a required field and must be a non-empty string", + fn -> + CurrencyAmount.new(%{amount: 10.00, currency_code: "USD"}) + end + end + + test "raises an error for nil amount" do + assert_raise ArgumentError, + "amount is a required field and must be a non-empty string", + fn -> + CurrencyAmount.new(%{amount: nil, currency_code: "USD"}) + end + end + end + + describe "new/1 with currency_code field" do + test "accepts valid ISO 4217 currency codes as strings" do + assert %CurrencyAmount{currency_code: "USD"} = + CurrencyAmount.new(%{amount: "10.00", currency_code: "USD"}) + + assert %CurrencyAmount{currency_code: "EUR"} = + CurrencyAmount.new(%{amount: "10.00", currency_code: "EUR"}) + + assert %CurrencyAmount{currency_code: "GBP"} = + CurrencyAmount.new(%{amount: "10.00", currency_code: "GBP"}) + + assert %CurrencyAmount{currency_code: "JPY"} = + CurrencyAmount.new(%{amount: "10.00", currency_code: "JPY"}) + + assert %CurrencyAmount{currency_code: "MYR"} = + CurrencyAmount.new(%{amount: "10.00", currency_code: "MYR"}) + end + + test "accepts valid ISO 4217 currency codes as atoms and converts to strings" do + currency_amount = CurrencyAmount.new(%{amount: "10.00", currency_code: :EUR}) + assert currency_amount.currency_code == "EUR" + end + + test "raises an error for invalid currency code" do + assert_raise ArgumentError, "Invalid currency code INVALID", fn -> + CurrencyAmount.new(%{amount: "10.00", currency_code: "INVALID"}) + end + end + + test "raises an error for nil currency_code" do + assert_raise ArgumentError, "currency_code is required", fn -> + CurrencyAmount.new(%{amount: "10.00", currency_code: nil}) + end + end + + test "raises an error for empty currency_code" do + assert_raise ArgumentError, "currency_code is required", fn -> + CurrencyAmount.new(%{amount: "10.00", currency_code: ""}) + end + end + + test "raises an error for whitespace-only currency_code" do + assert_raise ArgumentError, "currency_code is required", fn -> + CurrencyAmount.new(%{amount: "10.00", currency_code: " "}) + end + end + end + + describe "JSON encoding" do + test "encodes to JSON with camelCase keys" do + currency_amount = CurrencyAmount.new(%{amount: "25.50", currency_code: "EUR"}) + encoded = Jason.encode!(currency_amount) + + assert encoded =~ ~s("amount":"25.50") + assert encoded =~ ~s("currencyCode":"EUR") + refute encoded =~ "currency_code" + end + + test "produces valid JSON that can be decoded" do + currency_amount = CurrencyAmount.new(%{amount: "100", currency_code: "JPY"}) + encoded = Jason.encode!(currency_amount) + decoded = Jason.decode!(encoded) + + assert decoded["amount"] == "100" + assert decoded["currencyCode"] == "JPY" + end + end +end