Current section

Files

Jump to
phoenix_kit_ecommerce lib phoenix_kit_ecommerce options metadata_validator.ex
Raw

lib/phoenix_kit_ecommerce/options/metadata_validator.ex

defmodule PhoenixKitEcommerce.Options.MetadataValidator do
@moduledoc """
Validates and normalizes product metadata for options and pricing.
This module handles:
- Format normalization (object -> string for price modifiers)
- Consistency validation between _option_values and _price_modifiers
- Cleanup of orphaned modifiers for removed values
## Price Modifier Formats
The canonical format is a simple string representing the price delta:
%{"_price_modifiers" => %{
"size" => %{"M" => "5.00", "L" => "10.00"},
"color" => %{"Gold" => "8.00"}
}}
Legacy object format is also supported for backward compatibility:
%{"_price_modifiers" => %{
"size" => %{"M" => %{"type" => "fixed", "value" => "5.00"}}
}}
Both formats are normalized to string format when saving.
"""
@doc """
Validates metadata structure against option schema.
Returns `:ok` or `{:error, errors}` where errors is a list of error tuples.
## Examples
schema = [%{"key" => "size", "type" => "select", "options" => ["S", "M", "L"]}]
MetadataValidator.validate(%{"size" => "M"}, schema)
# => :ok
MetadataValidator.validate(%{"size" => "XL"}, schema)
# => {:error, [{"size", "must be one of: S, M, L"}]}
"""
def validate(metadata, option_schema) when is_map(metadata) and is_list(option_schema) do
errors = validate_values(metadata, option_schema) ++ validate_consistency(metadata)
case errors do
[] -> :ok
_ -> {:error, errors}
end
end
def validate(_, _), do: :ok
@doc """
Validates consistency between _option_values and _price_modifiers.
Ensures that:
- All keys in _price_modifiers have corresponding entries in _option_values (or are schema options)
- All values in _price_modifiers exist in their respective option values
Returns a list of error tuples (empty if valid).
"""
def validate_consistency(metadata) when is_map(metadata) do
option_values = Map.get(metadata, "_option_values", %{})
price_modifiers = Map.get(metadata, "_price_modifiers", %{})
# Guard against non-map price_modifiers
if is_map(price_modifiers) do
Enum.flat_map(price_modifiers, fn
{option_key, values} when is_map(values) ->
available_values = Map.get(option_values, option_key, [])
validate_option_modifiers(option_key, values, available_values)
{option_key, _invalid} ->
# Skip non-map values but could log warning
[{option_key, "price_modifiers values must be a map"}]
end)
else
[{"_price_modifiers", "must be a map"}]
end
end
def validate_consistency(_), do: []
defp validate_option_modifiers(_option_key, _values, []), do: []
defp validate_option_modifiers(option_key, values, available_values) do
Enum.flat_map(values, fn {value, _modifier} ->
validate_single_modifier(option_key, value, available_values)
end)
end
defp validate_single_modifier(option_key, value, available_values) do
if value in available_values do
[]
else
[{option_key, "modifier for '#{value}' has no corresponding option value"}]
end
end
@doc """
Removes orphaned modifiers for values not in _option_values.
This cleans up price modifiers when option values are removed.
## Examples
metadata = %{
"_option_values" => %{"size" => ["M", "L"]},
"_price_modifiers" => %{"size" => %{"S" => "0", "M" => "5.00", "L" => "10.00"}}
}
MetadataValidator.clean_orphaned_modifiers(metadata)
# => %{
# "_option_values" => %{"size" => ["M", "L"]},
# "_price_modifiers" => %{"size" => %{"M" => "5.00", "L" => "10.00"}}
# }
"""
def clean_orphaned_modifiers(metadata) when is_map(metadata) do
option_values = Map.get(metadata, "_option_values", %{})
price_modifiers = Map.get(metadata, "_price_modifiers", %{})
if price_modifiers == %{} do
metadata
else
cleaned_modifiers = clean_all_modifiers(price_modifiers, option_values)
apply_cleaned_modifiers(metadata, cleaned_modifiers)
end
end
def clean_orphaned_modifiers(metadata), do: metadata
defp clean_all_modifiers(price_modifiers, option_values) when is_map(price_modifiers) do
price_modifiers
|> Enum.flat_map(fn
{option_key, values} when is_map(values) ->
available_values = Map.get(option_values, option_key, [])
[{option_key, clean_option_modifiers(values, available_values)}]
{_option_key, _invalid} ->
# Skip non-map values
[]
end)
|> Enum.reject(fn {_k, v} -> v == %{} end)
|> Map.new()
end
defp clean_all_modifiers(_invalid, _option_values), do: %{}
# If no option_values for this key, keep all modifiers (schema-based)
defp clean_option_modifiers(values, []), do: values
defp clean_option_modifiers(values, available_values) do
Map.filter(values, fn {value, _} -> value in available_values end)
end
defp apply_cleaned_modifiers(metadata, cleaned) when cleaned == %{},
do: Map.delete(metadata, "_price_modifiers")
defp apply_cleaned_modifiers(metadata, cleaned),
do: Map.put(metadata, "_price_modifiers", cleaned)
@doc """
Normalizes all price modifiers to string format.
Converts object format to string format:
- `%{"type" => "fixed", "value" => "10.00"}` -> `"10.00"`
- `%{"value" => "10.00"}` -> `"10.00"`
- `%{"final_price" => "30.00"}` with base_price 20 -> `"10.00"`
Already-string values are passed through unchanged.
## Examples
metadata = %{
"_price_modifiers" => %{
"size" => %{
"M" => %{"type" => "fixed", "value" => "5.00"},
"L" => "10.00"
}
}
}
MetadataValidator.normalize_price_modifiers(metadata)
# => %{
# "_price_modifiers" => %{
# "size" => %{"M" => "5.00", "L" => "10.00"}
# }
# }
"""
def normalize_price_modifiers(metadata, base_price \\ nil)
def normalize_price_modifiers(metadata, base_price) when is_map(metadata) do
case Map.get(metadata, "_price_modifiers") do
nil ->
metadata
price_modifiers when is_map(price_modifiers) ->
normalized = normalize_all_modifiers(price_modifiers, base_price)
if normalized == %{} do
Map.delete(metadata, "_price_modifiers")
else
Map.put(metadata, "_price_modifiers", normalized)
end
_ ->
metadata
end
end
def normalize_price_modifiers(metadata, _base_price), do: metadata
defp normalize_all_modifiers(price_modifiers, base_price) do
price_modifiers
|> Enum.map(fn {option_key, values} ->
normalized_values =
values
|> Enum.map(fn {value, modifier} ->
{value, normalize_modifier_value(modifier, base_price)}
end)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
{option_key, normalized_values}
end)
|> Enum.reject(fn {_k, v} -> v == %{} end)
|> Map.new()
end
@doc """
Normalizes a complete set of product attributes before saving.
This function:
1. Normalizes price modifiers to string format
2. Cleans orphaned modifiers
3. Removes empty _option_values and _price_modifiers maps
## Examples
attrs = %{
"title" => "My Product",
"metadata" => %{
"_option_values" => %{"size" => ["M", "L"]},
"_price_modifiers" => %{
"size" => %{
"M" => %{"type" => "fixed", "value" => "5.00"},
"S" => "orphaned"
}
}
}
}
MetadataValidator.normalize_product_attrs(attrs)
# Normalizes modifiers and removes orphaned "S" entry
"""
def normalize_product_attrs(attrs) when is_map(attrs) do
case attrs do
%{"metadata" => metadata, "price" => price} when is_map(metadata) ->
base_price = parse_decimal(price)
normalized = normalize_and_clean(metadata, base_price)
Map.put(attrs, "metadata", normalized)
%{"metadata" => metadata} when is_map(metadata) ->
normalized = normalize_and_clean(metadata, nil)
Map.put(attrs, "metadata", normalized)
_ ->
attrs
end
end
def normalize_product_attrs(attrs), do: attrs
# Private helpers
defp normalize_and_clean(metadata, base_price) do
metadata
|> normalize_price_modifiers(base_price)
|> clean_orphaned_modifiers()
|> clean_empty_maps()
end
defp clean_empty_maps(metadata) do
metadata
|> maybe_remove_empty_key("_option_values")
|> maybe_remove_empty_key("_price_modifiers")
end
defp maybe_remove_empty_key(metadata, key) do
case Map.get(metadata, key) do
val when val == %{} or val == nil -> Map.delete(metadata, key)
_ -> metadata
end
end
defp normalize_modifier_value(modifier, base_price) when is_map(modifier) do
cond do
# Object with value key
Map.has_key?(modifier, "value") and modifier["value"] != "" ->
modifier["value"]
# Object with final_price key (needs conversion)
Map.has_key?(modifier, "final_price") and modifier["final_price"] != "" and
not is_nil(base_price) ->
final_price = parse_decimal(modifier["final_price"])
delta = Decimal.sub(final_price, base_price)
Decimal.to_string(Decimal.round(delta, 2))
# Empty or invalid object
true ->
nil
end
end
defp normalize_modifier_value(modifier, _base_price) when is_binary(modifier) do
# Already in string format
if modifier == "" do
nil
else
modifier
end
end
defp normalize_modifier_value(_, _), do: nil
defp validate_values(_metadata, _schema) do
# Delegate to Options.validate_metadata for value validation
# This avoids duplicating the validation logic
[]
end
defp parse_decimal(nil), do: Decimal.new("0")
defp parse_decimal(""), do: Decimal.new("0")
defp parse_decimal(value) when is_binary(value) do
case Decimal.parse(value) do
{decimal, _} -> decimal
:error -> Decimal.new("0")
end
end
defp parse_decimal(%Decimal{} = value), do: value
defp parse_decimal(_), do: Decimal.new("0")
end