Packages
phoenix_kit
1.7.39
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/shop/options/options.ex
defmodule PhoenixKit.Modules.Shop.Options do
@moduledoc """
Context for managing product options.
Provides a two-level option system:
- **Global options** - Apply to all products (stored in shop_config)
- **Category options** - Apply to products in specific category (stored in category.option_schema)
When retrieving options for a product, the system merges global and category options,
with category options overriding global ones by key.
## Localization Note
Option labels and values are currently stored as plain strings, not localized JSONB maps.
This means options display the same in all languages. Future enhancement: convert
option schema to support localized labels like `"label" => %{"en" => "Material", "ru" => "Материал"}`.
## Usage
# Get/set global options
Options.get_global_options()
Options.update_global_options([%{"key" => "material", "label" => "Material", "type" => "text"}])
# Get/set category options
Options.get_category_options(category)
Options.update_category_options(category, [%{"key" => "mounting_type", ...}])
# Get merged schema for a product
Options.get_option_schema_for_product(product)
# Validate product metadata against schema
Options.validate_metadata(product.metadata, schema)
## Price Calculation
Options with `affects_price: true` can modify the final product price.
Two modifier types are supported:
- `fixed` - Add exact amount (e.g., +$10)
- `percent` - Add percentage of base price (e.g., +20%)
## Allow Override
Options with `allow_override: true` can have their price modifiers customized
per-product. Override values are stored in product metadata under `_price_modifiers`.
When calculating price, the system checks for overrides first, then falls back
to the default values from the option schema.
Calculation order:
1. Sum all fixed modifiers (checking for overrides)
2. Add to base price (intermediate price)
3. Sum all percent modifiers (checking for overrides)
4. Apply percent to intermediate price
Example:
- Base price: $20
- Material: PETG (+$10 fixed)
- Finish: Premium (+20% percent)
- Final: ($20 + $10) * 1.20 = $36
"""
alias PhoenixKit.Modules.Shop.Category
alias PhoenixKit.Modules.Shop.OptionTypes
alias PhoenixKit.Modules.Shop.ShopConfig
@global_schema_key "global_option_schema"
# ============================================
# GLOBAL OPTIONS
# ============================================
@doc """
Gets global option schema.
Returns a list of option definitions that apply to all products.
"""
def get_global_options do
case repo().get(ShopConfig, @global_schema_key) do
nil -> []
%ShopConfig{value: %{"options" => opts}} when is_list(opts) -> opts
%ShopConfig{value: _} -> []
end
end
@doc """
Gets enabled global options only.
Filters out options where `enabled` is explicitly set to `false`.
Options without the `enabled` key default to enabled (backward compatible).
"""
def get_enabled_global_options do
get_global_options()
|> Enum.filter(fn opt -> Map.get(opt, "enabled", true) != false end)
end
@doc """
Updates global option schema.
## Examples
Options.update_global_options([
%{"key" => "material", "label" => "Material", "type" => "select",
"options" => ["PLA", "ABS", "PETG"], "default" => "PLA"}
])
"""
def update_global_options(options) when is_list(options) do
with {:ok, validated} <- OptionTypes.validate_options(options) do
wrapped_value = %{"options" => validated}
case repo().get(ShopConfig, @global_schema_key) do
nil ->
%ShopConfig{}
|> ShopConfig.changeset(%{key: @global_schema_key, value: wrapped_value})
|> repo().insert()
config ->
config
|> ShopConfig.changeset(%{value: wrapped_value})
|> repo().update()
end
end
end
@doc """
Adds a single option to global schema.
"""
def add_global_option(opt) when is_map(opt) do
with {:ok, validated} <- OptionTypes.validate_option(opt) do
current = get_global_options()
# Check for duplicate key
if Enum.any?(current, &(&1["key"] == validated["key"])) do
{:error, "Option with key '#{validated["key"]}' already exists"}
else
update_global_options(current ++ [validated])
end
end
end
@doc """
Removes an option from global schema by key.
"""
def remove_global_option(key) when is_binary(key) do
current = get_global_options()
updated = Enum.reject(current, &(&1["key"] == key))
update_global_options(updated)
end
@doc """
Gets a single global option by key.
Returns the option definition map or nil if not found.
## Examples
Options.get_global_option_by_key("color")
# => %{"key" => "color", "label" => "Color", "type" => "select", ...}
"""
def get_global_option_by_key(key) when is_binary(key) do
get_global_options()
|> Enum.find(&(&1["key"] == key))
end
def get_global_option_by_key(_), do: nil
@doc """
Adds a new value to an existing global option.
Works with both simple string options and enhanced map options.
For enhanced format, value_map should be a map with at least "value" key.
## Examples
# Simple format - adds "yellow" to options list
Options.add_value_to_global_option("color", "yellow")
# Enhanced format - adds map to options list
Options.add_value_to_global_option("color", %{
"value" => "yellow",
"label" => %{"en" => "Yellow", "ru" => "Жёлтый"},
"hex" => "#FFFF00"
})
"""
def add_value_to_global_option(key, value_or_map) when is_binary(key) do
case get_global_option_by_key(key) do
nil ->
{:error, "Global option '#{key}' not found"}
option ->
do_add_value_to_option(key, option, value_or_map)
end
end
defp do_add_value_to_option(key, option, value_or_map) do
current_options = option["options"] || []
new_value = normalize_option_value(value_or_map, current_options)
if value_exists?(current_options, new_value) do
{:ok, option}
else
updated_option = Map.put(option, "options", current_options ++ [new_value])
replace_global_option(key, updated_option)
end
end
defp replace_global_option(key, updated_option) do
all_options = get_global_options()
updated_all =
Enum.map(all_options, fn opt ->
if opt["key"] == key, do: updated_option, else: opt
end)
update_global_options(updated_all)
end
# Normalize value to match existing format (string or map)
defp normalize_option_value(value, current_options) when is_binary(value) do
# Check if current options are in enhanced format
if Enum.any?(current_options, &is_map/1) do
%{"value" => value, "label" => value}
else
value
end
end
defp normalize_option_value(value_map, _current_options) when is_map(value_map) do
value_map
end
defp normalize_option_value(value, _), do: to_string(value)
# Check if value already exists in options list
defp value_exists?(options, new_value) when is_binary(new_value) do
Enum.any?(options, fn opt ->
case opt do
^new_value -> true
%{"value" => ^new_value} -> true
_ -> false
end
end)
end
defp value_exists?(options, %{"value" => value}) do
value_exists?(options, value)
end
defp value_exists?(_, _), do: false
# ============================================
# CATEGORY OPTIONS
# ============================================
@doc """
Gets category-specific option schema.
"""
def get_category_options(%Category{option_schema: schema}) when is_list(schema) do
schema
end
def get_category_options(%Category{}) do
[]
end
def get_category_options(category_id) when is_integer(category_id) do
case repo().get_by(Category, id: category_id) do
nil -> []
category -> get_category_options(category)
end
end
def get_category_options(category_id) when is_binary(category_id) do
result =
if uuid_string?(category_id) do
repo().get_by(Category, uuid: category_id)
else
case Integer.parse(category_id) do
{int_id, ""} -> repo().get_by(Category, id: int_id)
_ -> nil
end
end
case result do
nil -> []
category -> get_category_options(category)
end
end
def get_category_options(_), do: []
@doc """
Updates category option schema.
"""
def update_category_options(%Category{} = category, options) when is_list(options) do
with {:ok, validated} <- OptionTypes.validate_options(options) do
category
|> Category.changeset(%{option_schema: validated})
|> repo().update()
end
end
@doc """
Adds a single option to category schema.
"""
def add_category_option(%Category{} = category, opt) when is_map(opt) do
with {:ok, validated} <- OptionTypes.validate_option(opt) do
current = get_category_options(category)
if Enum.any?(current, &(&1["key"] == validated["key"])) do
{:error, "Option with key '#{validated["key"]}' already exists in this category"}
else
update_category_options(category, current ++ [validated])
end
end
end
@doc """
Removes an option from category schema by key.
"""
def remove_category_option(%Category{} = category, key) when is_binary(key) do
current = get_category_options(category)
updated = Enum.reject(current, &(&1["key"] == key))
update_category_options(category, updated)
end
# ============================================
# MERGED SCHEMA (Global + Category)
# ============================================
@doc """
Gets merged option schema for a product.
Combines global options with category-specific options.
Category options override global ones with the same key.
## Examples
# Product with category
schema = Options.get_option_schema_for_product(product)
# Product without category (global only)
schema = Options.get_option_schema_for_product(product_without_category)
"""
def get_option_schema_for_product(product) do
global = get_enabled_global_options()
category_opts =
case product do
%{category: %Category{} = cat} -> get_category_options(cat)
%{category_uuid: nil, category_id: nil} -> []
%{category_uuid: uuid} when is_binary(uuid) -> get_category_options(uuid)
%{category_id: id} when is_integer(id) -> get_category_options(id)
_ -> []
end
merge_schemas(global, category_opts)
end
@doc """
Merges two option schemas, with the second overriding the first by key.
"""
def merge_schemas(base, override) when is_list(base) and is_list(override) do
override_keys = Enum.map(override, & &1["key"])
filtered_base =
Enum.reject(base, fn opt ->
opt["key"] in override_keys
end)
# Sort by position
(filtered_base ++ override)
|> Enum.sort_by(& &1["position"], :asc)
end
# ============================================
# SLOT-BASED OPTIONS
# ============================================
@doc """
Gets slot-based options for a product.
Resolves `_option_slots` from product metadata to full option specs.
Each slot references a global option via `source_key` and creates a
customized option spec with the slot's key and label.
## Examples
product.metadata = %{
"_option_slots" => [
%{"slot" => "cup_color", "label" => %{"en" => "Cup Color"}, "source_key" => "color"},
%{"slot" => "liquid_color", "label" => %{"en" => "Liquid"}, "source_key" => "color"}
]
}
Options.get_slot_options_for_product(product)
# => [
# %{"key" => "cup_color", "label" => %{"en" => "Cup Color"}, "type" => "select", ...},
# %{"key" => "liquid_color", "label" => %{"en" => "Liquid"}, "type" => "select", ...}
# ]
"""
def get_slot_options_for_product(product) do
metadata = product.metadata || %{}
slots = Map.get(metadata, "_option_slots", [])
Enum.flat_map(slots, fn slot ->
case resolve_slot_to_option(slot) do
nil -> []
option -> [option]
end
end)
end
@doc """
Resolves a single slot definition to a full option spec.
Takes a slot map with "slot", "label", and "source_key",
finds the referenced global option, and creates a new spec
with the slot's key and label but the source's type and values.
"""
def resolve_slot_to_option(%{"slot" => slot_key, "source_key" => source_key} = slot) do
case get_global_option_by_key(source_key) do
nil ->
nil
source_option ->
if Map.get(source_option, "enabled", true) == false do
nil
else
# Create new option spec using slot key/label but source's type/options
%{
"key" => slot_key,
"label" => slot["label"] || slot_key,
"type" => source_option["type"],
"options" => source_option["options"],
"source_key" => source_key,
"required" => Map.get(slot, "required", false),
"position" => Map.get(slot, "position", 0)
}
|> maybe_add_price_modifiers(source_option)
end
end
end
def resolve_slot_to_option(_), do: nil
# Copy price modifier settings from source option if present
defp maybe_add_price_modifiers(slot_option, source_option) do
if source_option["affects_price"] do
slot_option
|> Map.put("affects_price", true)
|> Map.put("modifier_type", source_option["modifier_type"] || "fixed")
|> Map.put("price_modifiers", source_option["price_modifiers"] || %{})
|> Map.put("allow_override", source_option["allow_override"] || false)
else
slot_option
end
end
@doc """
Gets complete option schema for a product including slot-based options.
This combines:
1. Global options (excluding those used as slot sources)
2. Category options
3. Slot-based options from product metadata
## Examples
Options.get_complete_option_schema_for_product(product)
"""
def get_complete_option_schema_for_product(product) do
base_schema = get_option_schema_for_product(product)
slot_options = get_slot_options_for_product(product)
# Get source keys used by slots to exclude from base schema
source_keys =
slot_options
|> Enum.map(& &1["source_key"])
|> Enum.reject(&is_nil/1)
|> MapSet.new()
# Filter out global options that are used as slot sources
# (but keep if allow_multiple_slots is false)
filtered_base =
Enum.reject(base_schema, fn opt ->
key = opt["key"]
MapSet.member?(source_keys, key) and OptionTypes.allows_multiple_slots?(opt)
end)
# Merge and sort by position
(filtered_base ++ slot_options)
|> Enum.sort_by(& &1["position"], :asc)
end
@doc """
Builds option slots structure for product metadata.
Creates the `_option_slots` array from a list of slot definitions.
## Examples
Options.build_option_slots([
%{slot: "cup_color", source_key: "color", label: %{"en" => "Cup Color"}},
%{slot: "liquid_color", source_key: "color", label: %{"en" => "Liquid"}}
])
# => [
# %{"slot" => "cup_color", "source_key" => "color", "label" => %{"en" => "Cup Color"}},
# %{"slot" => "liquid_color", "source_key" => "color", "label" => %{"en" => "Liquid"}}
# ]
"""
def build_option_slots(slots) when is_list(slots) do
Enum.map(slots, fn slot ->
%{
"slot" => to_string(slot[:slot] || slot["slot"]),
"source_key" => to_string(slot[:source_key] || slot["source_key"]),
"label" => slot[:label] || slot["label"] || slot[:slot] || slot["slot"]
}
|> maybe_add_position(slot)
end)
end
def build_option_slots(_), do: []
defp maybe_add_position(slot_map, source) do
position = source[:position] || source["position"]
if position, do: Map.put(slot_map, "position", position), else: slot_map
end
# ============================================
# VALUE VALIDATION
# ============================================
@doc """
Validates product metadata against option schema.
Returns `:ok` or `{:error, errors}` where errors is a list of `{key, message}` tuples.
## Examples
schema = [%{"key" => "material", "type" => "select", "options" => ["PLA", "ABS"], "required" => true}]
Options.validate_metadata(%{"material" => "PLA"}, schema)
# => :ok
Options.validate_metadata(%{}, schema)
# => {:error, [{"material", "is required"}]}
Options.validate_metadata(%{"material" => "Invalid"}, schema)
# => {:error, [{"material", "must be one of: PLA, ABS"}]}
"""
def validate_metadata(metadata, schema) when is_map(metadata) and is_list(schema) do
required_errors =
schema
|> Enum.filter(& &1["required"])
|> Enum.reject(fn opt ->
value = Map.get(metadata, opt["key"])
value != nil and value != ""
end)
|> Enum.map(fn opt -> {opt["key"], "is required"} end)
type_errors =
Enum.flat_map(schema, fn opt ->
value = Map.get(metadata, opt["key"])
validate_value_type(opt, value)
end)
case required_errors ++ type_errors do
[] -> :ok
errors -> {:error, errors}
end
end
def validate_metadata(_, _), do: :ok
# Skip validation for nil/empty values (handled by required check)
defp validate_value_type(_opt, nil), do: []
defp validate_value_type(_opt, ""), do: []
defp validate_value_type(%{"key" => key, "type" => "number"}, value) do
cond do
is_number(value) -> []
is_binary(value) and String.match?(value, ~r/^-?\d+\.?\d*$/) -> []
true -> [{key, "must be a number"}]
end
end
defp validate_value_type(%{"key" => key, "type" => "boolean"}, value) do
if is_boolean(value) or value in ["true", "false"] do
[]
else
[{key, "must be a boolean"}]
end
end
defp validate_value_type(%{"key" => key, "type" => "select", "options" => opts}, value) do
if value in opts do
[]
else
[{key, "must be one of: #{Enum.join(opts, ", ")}"}]
end
end
defp validate_value_type(%{"key" => key, "type" => "multiselect", "options" => opts}, value) do
values = if is_list(value), do: value, else: [value]
if Enum.all?(values, &(&1 in opts)) do
[]
else
[{key, "must be a list of: #{Enum.join(opts, ", ")}"}]
end
end
# text type accepts any string
defp validate_value_type(%{"type" => "text"}, _value), do: []
# Unknown type - skip validation
defp validate_value_type(_, _), do: []
# ============================================
# HELPER FUNCTIONS
# ============================================
@doc """
Returns option by key from a schema.
"""
def get_option_by_key(schema, key) when is_list(schema) and is_binary(key) do
Enum.find(schema, &(&1["key"] == key))
end
@doc """
Checks if an option key exists in schema.
"""
def has_option?(schema, key) when is_list(schema) and is_binary(key) do
Enum.any?(schema, &(&1["key"] == key))
end
# ============================================
# PRICE MODIFIER FUNCTIONS
# ============================================
@doc """
Gets price-affecting options from a schema.
Returns only options that have `affects_price: true` and are
of type `select` or `multiselect`.
## Examples
schema = [
%{"key" => "material", "type" => "select", "affects_price" => true, ...},
%{"key" => "notes", "type" => "text", ...}
]
Options.get_price_affecting_specs(schema)
# => [%{"key" => "material", ...}]
"""
def get_price_affecting_specs(schema) when is_list(schema) do
Enum.filter(schema, fn opt ->
opt["affects_price"] == true and opt["type"] in ["select", "multiselect"]
end)
end
def get_price_affecting_specs(_), do: []
@doc """
Gets all selectable options from a schema.
Returns options that are of type `select` or `multiselect` and not hidden.
Unlike `get_price_affecting_specs/1`, this includes options regardless of
whether they affect price. Use this for UI display of all selectable options.
## Examples
schema = [
%{"key" => "color", "type" => "select", "options" => ["Red", "Blue"]},
%{"key" => "material", "type" => "select", "affects_price" => true, ...},
%{"key" => "notes", "type" => "text", ...}
]
Options.get_selectable_specs(schema)
# => [%{"key" => "color", ...}, %{"key" => "material", ...}]
"""
def get_selectable_specs(schema) when is_list(schema) do
Enum.filter(schema, fn opt ->
opt["type"] in ["select", "multiselect"] and
Map.get(opt, "hidden", false) != true
end)
end
def get_selectable_specs(_), do: []
@doc """
Gets all selectable options for a specific product.
Combines global and category options, then filters for select/multiselect types.
Also discovers options from product metadata that have values defined.
Unlike `get_price_affecting_specs_for_product/1`, this includes all selectable
options regardless of whether they affect price.
Use this for displaying option selectors in the product UI.
"""
def get_selectable_specs_for_product(product) do
schema_specs =
product
|> get_option_schema_for_product()
|> get_selectable_specs()
|> filter_by_product_option_values(product)
# Discover additional options from product metadata (without price requirement)
discovered_specs = discover_selectable_options_from_metadata(product)
# Merge: schema specs take priority over discovered
merge_discovered_specs(schema_specs, discovered_specs)
end
@doc """
Gets all selectable options for admin product detail view.
Unlike `get_selectable_specs_for_product/1`, this does NOT filter schema options
by product's `_option_values`. Shows all schema options (global + category) plus
discovered options from metadata, giving admins the full picture.
"""
def get_all_selectable_specs_for_admin(product) do
# All schema selectable specs WITHOUT filtering by _option_values
schema_specs =
product
|> get_option_schema_for_product()
|> get_selectable_specs()
# Discover additional options from product metadata
discovered_specs = discover_selectable_options_from_metadata(product)
# Merge: schema specs take priority over discovered
merge_discovered_specs(schema_specs, discovered_specs)
end
# Discovers selectable options from product metadata.
# Creates "virtual" option specs for keys found in _option_values.
# Unlike discover_options_from_metadata/1, this doesn't require _price_modifiers.
defp discover_selectable_options_from_metadata(product) do
metadata = product.metadata || %{}
option_values = Map.get(metadata, "_option_values", %{})
price_modifiers = Map.get(metadata, "_price_modifiers", %{})
# For each key in _option_values that has values
option_values
|> Enum.filter(fn {_key, values} ->
is_list(values) and values != []
end)
|> Enum.map(fn {key, values} ->
# Check if this option has price modifiers with at least one non-zero value
key_modifiers = Map.get(price_modifiers, key, %{})
has_price = key_modifiers != %{} and has_nonzero_modifiers?(key_modifiers)
base_spec = %{
"key" => key,
"label" => humanize_key(key),
"type" => "select",
"options" => values,
"_discovered" => true
}
if has_price do
base_spec
|> Map.put("affects_price", true)
|> Map.put("modifier_type", "fixed")
|> Map.put("allow_override", true)
|> Map.put("price_modifiers", key_modifiers)
else
base_spec
end
end)
end
@doc """
Gets price-affecting options for a specific product.
Combines global and category options, then filters for price-affecting ones.
If the product has `_option_values` in metadata, only returns options
for which the product has values. This allows products without certain
options (e.g., Size) to skip required validation for those options.
Additionally, discovers options from product metadata that have price modifiers
but are not defined in the schema (e.g., imported products with custom options).
"""
def get_price_affecting_specs_for_product(product) do
schema_specs =
product
|> get_option_schema_for_product()
|> get_price_affecting_specs()
|> filter_by_product_option_values(product)
# Discover additional options from product metadata
discovered_specs = discover_options_from_metadata(product)
# Merge: schema specs take priority over discovered
merge_discovered_specs(schema_specs, discovered_specs)
end
# Filters options - keeps only those for which product has values in metadata.
# If product has no _option_values, returns all options (backward compatibility).
# Also keeps schema specs that have image mappings AND their own defined options.
defp filter_by_product_option_values(specs, product) do
metadata = product.metadata || %{}
option_values = Map.get(metadata, "_option_values", %{})
# Only filter if product has _option_values (imported products)
if option_values != %{} do
image_mappings = Map.get(metadata, "_image_mappings", %{})
Enum.filter(specs, fn spec ->
key = spec["key"]
case Map.get(option_values, key) do
values when is_list(values) and values != [] ->
true
_ ->
# Keep schema specs that have their own defined options or image mappings
has_image_mappings = is_map(image_mappings[key]) and image_mappings[key] != %{}
has_own_options = is_list(spec["options"]) and spec["options"] != []
has_own_options or has_image_mappings
end
end)
else
# No _option_values - return all options (backward compatibility)
specs
end
end
# Discovers options from product metadata that have price modifiers with non-zero values.
# Creates "virtual" option specs for keys found in _option_values that also
# have corresponding _price_modifiers entries with at least one non-zero modifier.
defp discover_options_from_metadata(product) do
metadata = product.metadata || %{}
option_values = Map.get(metadata, "_option_values", %{})
price_modifiers = Map.get(metadata, "_price_modifiers", %{})
# For each key in _option_values that has _price_modifiers with non-zero values
option_values
|> Enum.filter(fn {key, values} ->
key_modifiers = Map.get(price_modifiers, key, %{})
is_list(values) and values != [] and
key_modifiers != %{} and has_nonzero_modifiers?(key_modifiers)
end)
|> Enum.map(fn {key, values} ->
%{
"key" => key,
"label" => humanize_key(key),
"type" => "select",
"options" => values,
"affects_price" => true,
"modifier_type" => "fixed",
"allow_override" => true,
"price_modifiers" => Map.get(price_modifiers, key, %{}),
"_discovered" => true
}
end)
end
# Checks if a price modifiers map has at least one non-zero value.
# Used to determine if an option group actually affects pricing.
defp has_nonzero_modifiers?(modifiers) when is_map(modifiers) do
Enum.any?(modifiers, fn {_key, value} ->
decimal =
case value do
%{"value" => v} when is_binary(v) -> safe_parse_decimal(v)
v when is_binary(v) -> safe_parse_decimal(v)
_ -> nil
end
decimal != nil and Decimal.compare(decimal, Decimal.new("0")) != :eq
end)
end
defp has_nonzero_modifiers?(_), do: false
defp safe_parse_decimal(value) when is_binary(value) do
case Decimal.parse(value) do
{decimal, ""} -> decimal
_ -> nil
end
end
# Converts snake_case key to human-readable label.
# Example: "main_color" -> "Main Color"
defp humanize_key(key) do
key
|> String.replace("_", " ")
|> String.split(" ")
|> Enum.map_join(" ", &String.capitalize/1)
end
# Merges schema specs with discovered specs.
# Schema specs take priority - discovered specs are only added if their key
# is not already in the schema.
defp merge_discovered_specs(schema_specs, discovered_specs) do
schema_keys = Enum.map(schema_specs, & &1["key"]) |> MapSet.new()
# Only add discovered specs not already in schema
new_specs =
Enum.reject(discovered_specs, fn spec ->
MapSet.member?(schema_keys, spec["key"])
end)
schema_specs ++ new_specs
end
@doc """
Gets the price modifier for a specific option value.
Returns a Decimal value representing the price delta for the selected option.
Returns Decimal.new("0") if the option has no modifier or option doesn't affect price.
For "custom" modifier type, the modifiers come from product metadata.
## Examples
opt = %{
"key" => "material",
"affects_price" => true,
"price_modifiers" => %{"PLA" => "0", "PETG" => "10.00"}
}
Options.get_price_modifier(opt, "PETG")
# => Decimal.new("10.00")
Options.get_price_modifier(opt, "PLA")
# => Decimal.new("0")
"""
def get_price_modifier(%{"affects_price" => true, "price_modifiers" => modifiers}, value)
when is_map(modifiers) and is_binary(value) do
case Map.get(modifiers, value) do
nil ->
Decimal.new("0")
modifier when is_binary(modifier) ->
parse_decimal(modifier)
end
end
def get_price_modifier(_, _), do: Decimal.new("0")
@doc """
Gets the effective modifier info (type and value) for an option, checking for product overrides.
Returns `{modifier_type, modifier_value}` tuple.
If the option has `allow_override: true` and the product has an override in metadata,
uses the override type and value. Otherwise uses defaults from option's schema.
## Override Structure
Overrides in metadata can be:
- New format: `%{"type" => "fixed", "value" => "10.00"}` - custom type and value
- Legacy format: `"10.00"` - just value, inherits option's default type
## Examples
# Option with custom override (type + value)
opt = %{"key" => "material", "allow_override" => true, "modifier_type" => "fixed", ...}
metadata = %{"_price_modifiers" => %{"material" => %{"PETG" => %{"type" => "percent", "value" => "15"}}}}
get_effective_modifier_info(opt, "PETG", metadata)
# => {"percent", Decimal.new("15")}
# Option with legacy override (just value)
metadata = %{"_price_modifiers" => %{"material" => %{"PETG" => "15.00"}}}
get_effective_modifier_info(opt, "PETG", metadata)
# => {"fixed", Decimal.new("15.00")} # Uses option's default type
"""
def get_effective_modifier_info(opt, selected_value, metadata)
def get_effective_modifier_info(
%{"key" => key, "allow_override" => true, "modifier_type" => default_type} = opt,
selected_value,
metadata
)
when is_binary(selected_value) and is_map(metadata) do
case get_override_info(metadata, key, selected_value) do
{:ok, type, value} ->
{type || default_type, Decimal.new(value)}
:not_found ->
default_value = get_price_modifier(opt, selected_value)
{default_type, default_value}
end
end
def get_effective_modifier_info(
%{"modifier_type" => default_type} = opt,
selected_value,
_metadata
) do
# No allow_override - use defaults
{default_type, get_price_modifier(opt, selected_value)}
end
def get_effective_modifier_info(opt, selected_value, _metadata) do
# Fallback: fixed type
{"fixed", get_price_modifier(opt, selected_value)}
end
# Helper to get override info from metadata (type + value)
defp get_override_info(metadata, option_key, option_value) do
case metadata do
%{"_price_modifiers" => %{^option_key => modifiers}} when is_map(modifiers) ->
case Map.get(modifiers, option_value) do
# New format: %{"type" => "percent", "value" => "15"}
%{"type" => type, "value" => value} when is_binary(value) and value != "" ->
{:ok, type, value}
%{"value" => value} when is_binary(value) and value != "" ->
{:ok, nil, value}
# Legacy format: just a string value
value when is_binary(value) and value != "" ->
{:ok, nil, value}
_ ->
:not_found
end
_ ->
:not_found
end
end
# Legacy function - kept for backward compatibility
def get_effective_modifier(opt, selected_value, metadata) do
{_type, value} = get_effective_modifier_info(opt, selected_value, metadata)
value
end
@doc """
Gets the price modifier for overridden values from product metadata.
Used when option has `allow_override: true` and the product has custom values
stored in metadata under `_price_modifiers` key.
## Examples
product_metadata = %{
"_price_modifiers" => %{
"material" => %{"PLA" => "0", "PETG" => "15.00"}
}
}
Options.get_custom_price_modifier(product_metadata, "material", "PETG")
# => Decimal.new("15.00")
"""
def get_custom_price_modifier(metadata, option_key, option_value)
when is_map(metadata) and is_binary(option_key) and is_binary(option_value) do
case metadata do
%{"_price_modifiers" => %{^option_key => modifiers}} when is_map(modifiers) ->
case Map.get(modifiers, option_value) do
nil -> Decimal.new("0")
"" -> Decimal.new("0")
modifier when is_binary(modifier) -> Decimal.new(modifier)
_ -> Decimal.new("0")
end
_ ->
Decimal.new("0")
end
end
def get_custom_price_modifier(_, _, _), do: Decimal.new("0")
@doc """
Calculates total price modifier for selected specifications.
Takes a list of price-affecting options, a map of selected values, and the base price.
Returns the final price after applying all modifiers.
## Options
- `product_metadata` - Optional product metadata for custom modifier values.
When provided, options with `modifier_type: "custom"` will use price values
from `metadata["_price_modifiers"][option_key][option_value]`.
## Calculation Order
1. Sum all fixed modifiers (from schema price_modifiers)
2. Sum all custom modifiers (from product metadata)
3. Add to base price (intermediate price)
4. Sum all percent modifiers
5. Apply percent to intermediate price: intermediate * (1 + percent_sum/100)
## Examples
specs = [
%{"key" => "material", "affects_price" => true, "modifier_type" => "fixed",
"price_modifiers" => %{"PLA" => "0", "PETG" => "10.00"}},
%{"key" => "finish", "affects_price" => true, "modifier_type" => "percent",
"price_modifiers" => %{"Standard" => "0", "Premium" => "20"}}
]
selected = %{"material" => "PETG", "finish" => "Premium"}
base_price = Decimal.new("20.00")
Options.calculate_final_price(specs, selected, base_price)
# => Decimal.new("36.00") # ($20 + $10) * 1.20
"""
def calculate_final_price(specs, selected_specs, base_price, product_metadata \\ %{})
def calculate_final_price(specs, selected_specs, base_price, product_metadata)
when is_list(specs) and is_map(selected_specs) do
base = if is_nil(base_price), do: Decimal.new("0"), else: base_price
metadata = product_metadata || %{}
# For each option, get the effective type and value (considering overrides)
# Then group by effective type
modifiers =
Enum.map(specs, fn opt ->
selected_value = Map.get(selected_specs, opt["key"])
if selected_value do
{type, value} = get_effective_modifier_info(opt, selected_value, metadata)
{type, value}
else
nil
end
end)
|> Enum.reject(&is_nil/1)
# Split into fixed and percent based on effective type
{fixed_modifiers, percent_modifiers} =
Enum.split_with(modifiers, fn {type, _value} -> type == "fixed" end)
# Sum fixed modifiers
fixed_sum =
Enum.reduce(fixed_modifiers, Decimal.new("0"), fn {_type, value}, acc ->
Decimal.add(acc, value)
end)
# Calculate intermediate price (base + fixed)
intermediate = Decimal.add(base, fixed_sum)
# Sum percent modifiers
percent_sum =
Enum.reduce(percent_modifiers, Decimal.new("0"), fn {_type, value}, acc ->
Decimal.add(acc, value)
end)
# Apply percent modifier: intermediate * (1 + percent_sum/100)
if Decimal.compare(percent_sum, Decimal.new("0")) == :gt do
multiplier = Decimal.add(Decimal.new("1"), Decimal.div(percent_sum, Decimal.new("100")))
Decimal.mult(intermediate, multiplier) |> Decimal.round(2)
else
intermediate
end
end
def calculate_final_price(_, _, base_price, _), do: base_price || Decimal.new("0")
@doc """
Calculates total modifier amount (for backward compatibility).
This function returns just the sum of fixed modifiers.
For full calculation with percent modifiers, use `calculate_final_price/3`.
## Examples
specs = [
%{"key" => "material", "affects_price" => true, "price_modifiers" => %{"PETG" => "10.00"}},
%{"key" => "color", "affects_price" => true, "price_modifiers" => %{"Gold" => "8.00"}}
]
selected = %{"material" => "PETG", "color" => "Gold"}
Options.calculate_total_modifier(specs, selected)
# => Decimal.new("18.00")
"""
def calculate_total_modifier(specs, selected_specs)
when is_list(specs) and is_map(selected_specs) do
# Only sum fixed modifiers for backward compatibility
fixed_specs =
Enum.filter(specs, fn opt ->
Map.get(opt, "modifier_type", "fixed") == "fixed"
end)
Enum.reduce(fixed_specs, Decimal.new("0"), fn opt, acc ->
selected_value = Map.get(selected_specs, opt["key"])
modifier = get_price_modifier(opt, selected_value)
Decimal.add(acc, modifier)
end)
end
def calculate_total_modifier(_, _), do: Decimal.new("0")
@doc """
Gets the min/max price range for a list of options.
For each option, finds the minimum and maximum modifier values,
then calculates the final price range considering both fixed and percent modifiers.
Returns `{min_price, max_price}` as Decimals.
## Examples
specs = [
%{"key" => "material", "modifier_type" => "fixed",
"price_modifiers" => %{"PLA" => "0", "PETG" => "10.00"}},
%{"key" => "finish", "modifier_type" => "percent",
"price_modifiers" => %{"Standard" => "0", "Premium" => "20"}}
]
base_price = Decimal.new("20.00")
Options.get_price_range(specs, base_price)
# => {Decimal.new("20.00"), Decimal.new("36.00")}
"""
def get_price_range(specs, base_price, product_metadata \\ %{})
def get_price_range(specs, base_price, product_metadata) when is_list(specs) do
base = if is_nil(base_price), do: Decimal.new("0"), else: base_price
metadata = product_metadata || %{}
if Enum.empty?(specs) do
{base, base}
else
# Separate by modifier type: fixed vs percent
{fixed_specs, percent_specs} =
Enum.split_with(specs, fn opt ->
Map.get(opt, "modifier_type", "fixed") == "fixed"
end)
# Calculate min/max for fixed modifiers (considering overrides)
{fixed_min, fixed_max} = get_effective_modifier_range(fixed_specs, metadata)
# Calculate min/max for percent modifiers (considering overrides)
{percent_min, percent_max} = get_effective_modifier_range(percent_specs, metadata)
# Calculate min price: (base + fixed_min) * (1 + percent_min/100)
min_intermediate = Decimal.add(base, fixed_min)
min_price =
if Decimal.compare(percent_min, Decimal.new("0")) == :gt do
multiplier =
Decimal.add(Decimal.new("1"), Decimal.div(percent_min, Decimal.new("100")))
Decimal.mult(min_intermediate, multiplier) |> Decimal.round(2)
else
min_intermediate
end
# Calculate max price: (base + fixed_max) * (1 + percent_max/100)
max_intermediate = Decimal.add(base, fixed_max)
max_price =
if Decimal.compare(percent_max, Decimal.new("0")) == :gt do
multiplier =
Decimal.add(Decimal.new("1"), Decimal.div(percent_max, Decimal.new("100")))
Decimal.mult(max_intermediate, multiplier) |> Decimal.round(2)
else
max_intermediate
end
{min_price, max_price}
end
end
def get_price_range(_, base_price, _) do
base = if is_nil(base_price), do: Decimal.new("0"), else: base_price
{base, base}
end
@doc """
Gets the min/max modifier range for a list of options.
Returns `{min_total, max_total}` as Decimals.
"""
def get_modifier_range(specs) when is_list(specs) do
Enum.reduce(specs, {Decimal.new("0"), Decimal.new("0")}, fn opt, {min_acc, max_acc} ->
modifiers = opt["price_modifiers"] || %{}
values = Map.values(modifiers) |> Enum.map(&parse_decimal/1)
if Enum.empty?(values) do
{min_acc, max_acc}
else
{
Decimal.add(min_acc, Enum.min(values)),
Decimal.add(max_acc, Enum.max(values))
}
end
end)
end
def get_modifier_range(_), do: {Decimal.new("0"), Decimal.new("0")}
@doc """
Gets the min/max modifier range for options, considering product overrides.
For options with `allow_override: true`, checks if product has override values
in metadata and uses those instead of defaults.
Returns `{min_total, max_total}` as Decimals.
"""
def get_effective_modifier_range(specs, metadata) when is_list(specs) and is_map(metadata) do
Enum.reduce(specs, {Decimal.new("0"), Decimal.new("0")}, fn opt, {min_acc, max_acc} ->
option_key = opt["key"]
allow_override = opt["allow_override"] == true
default_modifiers = opt["price_modifiers"] || %{}
# Get modifiers: check for overrides first, then fall back to defaults
modifiers =
if allow_override do
case metadata do
%{"_price_modifiers" => %{^option_key => mods}} when is_map(mods) ->
# Merge: override values take precedence
Map.merge(default_modifiers, mods)
_ ->
default_modifiers
end
else
default_modifiers
end
values = Map.values(modifiers) |> Enum.map(&parse_decimal/1)
if Enum.empty?(values) do
{min_acc, max_acc}
else
{
Decimal.add(min_acc, Enum.min(values)),
Decimal.add(max_acc, Enum.max(values))
}
end
end)
end
def get_effective_modifier_range(specs, _metadata) when is_list(specs) do
get_modifier_range(specs)
end
def get_effective_modifier_range(_, _), do: {Decimal.new("0"), Decimal.new("0")}
defp parse_decimal(value) when is_binary(value) do
case Decimal.parse(value) do
{decimal, ""} ->
decimal
_ ->
require Logger
Logger.warning("[Shop.Options] Invalid price modifier value: #{inspect(value)}")
Decimal.new("0")
end
end
defp parse_decimal(value) do
require Logger
if value not in [nil, ""] do
Logger.warning("[Shop.Options] Unexpected price modifier type: #{inspect(value)}")
end
Decimal.new("0")
end
# ============================================
# PRIVATE
# ============================================
defp repo, do: PhoenixKit.RepoHelper.repo()
defp uuid_string?(string) when is_binary(string) do
match?({:ok, _}, Ecto.UUID.cast(string))
end
end