Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions lib/structs/semantic_tags/currency_amount.ex
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
cursor[bot] marked this conversation as resolved.
end
end
162 changes: 162 additions & 0 deletions test/structs/semantic_tags/currency_amount_test.exs
Original file line number Diff line number Diff line change
@@ -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