From ed7bfbcfa50d399e1c758d2722760cd214f7df75 Mon Sep 17 00:00:00 2001 From: njausteve Date: Tue, 27 Jan 2026 15:18:53 +0800 Subject: [PATCH 1/3] feat(semantic-tags): add CurrencyAmount struct with amount and currency_code fields Implements Issue #115 (amount field) and #98 (currency_code field). The CurrencyAmount struct represents monetary values with their associated ISO 4217 currency code, commonly used in semantic tags for balance, price, or other financial information in Apple Wallet passes. Features: - Required 'amount' field (string) for the monetary value - Required 'currency_code' field validated against ISO 4217 codes - Accepts currency codes as both strings and atoms (atoms converted to strings) - JSON encoding with camelCase keys for Apple Wallet compatibility - Comprehensive validation and error messages - Full test coverage (21 tests including doctests) Closes #115 Closes #98 --- lib/structs/semantic_tags/currency_amount.ex | 101 +++++++++++++ .../semantic_tags/currency_amount_test.exs | 140 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 lib/structs/semantic_tags/currency_amount.ex create mode 100644 test/structs/semantic_tags/currency_amount_test.exs diff --git a/lib/structs/semantic_tags/currency_amount.ex b/lib/structs/semantic_tags/currency_amount.ex new file mode 100644 index 0000000..0d571d2 --- /dev/null +++ b/lib/structs/semantic_tags/currency_amount.ex @@ -0,0 +1,101 @@ +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(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..84114bb --- /dev/null +++ b/test/structs/semantic_tags/currency_amount_test.exs @@ -0,0 +1,140 @@ +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 + 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 From 46033f66e83184c8e2e8a88641fd1c83cc1cf2fe Mon Sep 17 00:00:00 2001 From: njausteve Date: Tue, 27 Jan 2026 15:21:34 +0800 Subject: [PATCH 2/3] style: fix formatting in currency_amount_test.exs --- .../semantic_tags/currency_amount_test.exs | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/test/structs/semantic_tags/currency_amount_test.exs b/test/structs/semantic_tags/currency_amount_test.exs index 84114bb..3663dd1 100644 --- a/test/structs/semantic_tags/currency_amount_test.exs +++ b/test/structs/semantic_tags/currency_amount_test.exs @@ -8,9 +8,11 @@ defmodule ExPass.Structs.SemanticTags.CurrencyAmountTest do 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 + assert_raise ArgumentError, + "amount is a required field and must be a non-empty string", + fn -> + CurrencyAmount.new() + end end end @@ -34,9 +36,11 @@ defmodule ExPass.Structs.SemanticTags.CurrencyAmountTest do 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 + 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 @@ -64,21 +68,27 @@ defmodule ExPass.Structs.SemanticTags.CurrencyAmountTest do 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 + 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 + 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 + 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 From b4160ddf72d13f98cb3ad3fb64d9fdfde93453d0 Mon Sep 17 00:00:00 2001 From: njausteve Date: Tue, 27 Jan 2026 15:29:37 +0800 Subject: [PATCH 3/3] fix: handle empty string currency_code with proper error message Empty or whitespace-only currency_code now returns 'currency_code is required' instead of 'Invalid currency code ' for consistency with amount field validation. Added tests for empty and whitespace-only currency_code cases. --- lib/structs/semantic_tags/currency_amount.ex | 4 ++++ test/structs/semantic_tags/currency_amount_test.exs | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/structs/semantic_tags/currency_amount.ex b/lib/structs/semantic_tags/currency_amount.ex index 0d571d2..732eccb 100644 --- a/lib/structs/semantic_tags/currency_amount.ex +++ b/lib/structs/semantic_tags/currency_amount.ex @@ -95,6 +95,10 @@ defmodule ExPass.Structs.SemanticTags.CurrencyAmount 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 diff --git a/test/structs/semantic_tags/currency_amount_test.exs b/test/structs/semantic_tags/currency_amount_test.exs index 3663dd1..41618d4 100644 --- a/test/structs/semantic_tags/currency_amount_test.exs +++ b/test/structs/semantic_tags/currency_amount_test.exs @@ -126,6 +126,18 @@ defmodule ExPass.Structs.SemanticTags.CurrencyAmountTest do 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