# `PhoenixKitEcommerce`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ecommerce/blob/v0.1.8/lib/phoenix_kit_ecommerce.ex#L1)

E-commerce Shop Module for PhoenixKit.

Provides comprehensive e-commerce functionality including products, categories,
options-based pricing, and cart management.

## Features

- **Products**: Physical and digital products with JSONB flexibility
- **Categories**: Hierarchical product categories
- **Options**: Product options with dynamic pricing (fixed or percent modifiers)
- **Inventory**: Stock tracking with reservation system
- **Cart**: Persistent shopping cart (DB-backed for cross-device support)

## System Enable/Disable

    # Check if shop is enabled
    PhoenixKitEcommerce.enabled?()

    # Enable/disable shop system
    PhoenixKitEcommerce.enable_system()
    PhoenixKitEcommerce.disable_system()

## Integration with Billing

Shop integrates with the Billing module for orders and payments.
Order line_items include shop metadata for product tracking.

# `add_to_cart`

Adds item to cart.

## Options
- `:selected_specs` - Map of selected specifications (for dynamic pricing)

## Examples

    # Add simple product
    add_to_cart(cart, product, 2)

    # Add product with specification-based pricing
    add_to_cart(cart, product, 1, selected_specs: %{"material" => "PETG", "color" => "Gold"})

# `aggregate_filter_values`

Aggregates filter values for sidebar display.

Returns a map of filter_key => aggregated data.
For price_range: %{min: Decimal, max: Decimal}
For vendor: [%{value: "Vendor", count: 5}, ...]
For metadata_option: [%{value: "8 inches", count: 3}, ...]

Options:
- `:category_uuid` - Scope aggregation to a specific category by UUID

# `auto_select_payment_option`

Auto-selects payment option if only one is available.

If cart already has a payment option selected, does nothing.
If only one option is available, selects it.

# `auto_select_shipping_method`

Auto-selects the cheapest available shipping method for a cart.

If cart already has a shipping method selected, does nothing.
If only one method is available, selects it.
If multiple methods are available, selects the cheapest one.

# `bulk_delete_categories`

Bulk delete categories.
Returns count of deleted categories. Nullifies category references on orphaned products.

# `bulk_delete_products`

Bulk delete products.
Returns count of deleted products.

# `bulk_update_category_parent`

Bulk update category parent.
Returns count of updated categories. Excludes the target parent from the update set
to prevent self-reference. Uses a single UPDATE with subquery to resolve parent_uuid.

# `bulk_update_category_status`

Bulk update category status.
Returns count of updated categories.

# `bulk_update_product_category`

Bulk update product category.
Returns count of updated products.

# `bulk_update_product_status`

Bulk update product status.
Returns count of updated products.

# `calculate_product_price`

Calculates the final price for a product based on selected specifications.

Applies option price modifiers (fixed and percent) to the base price.
Fixed modifiers are applied first, then percent modifiers.

## Example

    product = %Product{price: Decimal.new("20.00")}
    selected_specs = %{"material" => "PETG", "finish" => "Premium"}

    # If PETG has +$10 fixed and Premium has +20% percent:
    calculate_product_price(product, selected_specs)
    # => Decimal.new("36.00")  # ($20 + $10) * 1.20

# `cart_url`

```elixir
@spec cart_url(String.t()) :: String.t()
```

Generates a localized URL for the cart page.

## Examples

    iex> Shop.cart_url("ru")
    "/ru/cart"

    iex> Shop.cart_url("en")
    "/cart"

# `catalog_url`

```elixir
@spec catalog_url(String.t()) :: String.t()
```

Generates a localized URL for the shop catalog.

## Examples

    iex> Shop.catalog_url("es-ES")
    "/es/shop"

    iex> Shop.catalog_url("en")
    "/shop"

# `category_options`

Returns categories as options for select input.
Returns list of {localized_name, id} tuples.

# `category_slug_exists?`

Checks if a category slug exists for a language.

## Examples

    iex> Shop.category_slug_exists?("jarrones-macetas", "es-ES")
    true

# `category_url`

```elixir
@spec category_url(PhoenixKitEcommerce.Category.t(), String.t()) :: String.t()
```

Generates a localized URL for a category.

Returns the correct locale-prefixed URL with translated slug.

## Parameters

  - `category` - The Category struct
  - `language` - Language code (e.g., "en-US", "ru", "es-ES")

## Examples

    iex> Shop.category_url(category, "es-ES")
    "/es/shop/category/jarrones-macetas"

    iex> Shop.category_url(category, "en")
    "/shop/category/vases-planters"  # Default language - no prefix

# `change_category`

Returns a changeset for category form.

# `change_import_config`

Returns a changeset for tracking import config changes.

# `change_product`

Returns a changeset for product form.

# `change_shipping_method`

Returns a changeset for shipping method form.

# `checkout_url`

```elixir
@spec checkout_url(String.t()) :: String.t()
```

Generates a localized URL for the checkout page.

## Examples

    iex> Shop.checkout_url("ru")
    "/ru/checkout"

    iex> Shop.checkout_url("en")
    "/checkout"

# `clear_cart`

Clears all items from cart.

# `collect_product_file_uuids`

Collects all storage file UUIDs associated with a single product.

# `collect_products_file_uuids`

Collects all storage file UUIDs for a list of product UUIDs.

# `complete_import`

Marks import as completed.

# `convert_cart_to_order`

Converts a cart to a Billing.Order.

Takes an active cart with items and creates an Order with:
- All cart items as line_items
- Shipping as additional line item (if selected)
- Billing profile snapshot (from profile_uuid or direct billing_data)
- Cart marked as "converted"

For guest checkout (no user_uuid on cart):
- Creates a guest user via `Auth.create_guest_user/1`
- Guest user has `confirmed_at = nil` until email verification
- Sends confirmation email automatically
- Order remains in "pending" status

## Options

- `billing_profile_uuid: uuid` - Use existing billing profile (for logged-in users)
- `billing_data: map` - Use direct billing data (for guest checkout)

## Returns

- `{:ok, order}` - Order created successfully
- `{:error, :cart_not_active}` - Cart is not active
- `{:error, :cart_empty}` - Cart has no items
- `{:error, :no_shipping_method}` - No shipping method selected
- `{:error, :email_already_registered}` - Guest email belongs to confirmed user
- `{:error, changeset}` - Validation errors

# `count_active_carts`

Counts active carts.

# `create_cart`

Creates a new cart.

# `create_category`

Creates a new category.

# `create_import_config`

Creates an import config.

# `create_import_log`

Creates a new import log entry.

# `create_product`

Creates a new product.

Automatically normalizes metadata (price modifiers, option values)
before saving to ensure consistent storage format.

# `create_shipping_method`

Creates a new shipping method.

# `default_storefront_filters`

Returns the default storefront filter configuration.

# `delete_category`

Deletes a category.

# `delete_import_config`

Deletes an import config.

# `delete_import_log`

Deletes an import log.

# `delete_product`

Deletes a product.

# `delete_shipping_method`

Deletes a shipping method.

# `disable_system`

Disables the shop system.

# `discover_filterable_options`

Discovers filterable option keys from product metadata.

Returns a list of {key, product_count} tuples sorted by count descending.
Used by admin UI to auto-suggest available filters.

# `enable_system`

Enables the shop system.

# `enabled?`

Checks if the shop system is enabled.

# `ensure_default_import_config`

Creates the legacy default import config if no configs exist.

Returns `{:created, config}` if a new config was created,
or `:exists` if configs already exist.

# `ensure_featured_product`

Ensures a category has a featured_product_uuid set.

If the category has no image_uuid and no featured_product_uuid, auto-detects the
first active product with an image and saves it. Returns the (possibly updated)
category with :featured_product preloaded.

# `ensure_prom_ua_import_config`

Ensures a default Prom.ua import config exists.
Creates one if no config with name "prom_ua_default" is found.

# `expire_old_carts`

Expires old guest carts.

# `fail_import`

Marks import as failed.

# `find_active_cart`

Finds active cart by user_uuid or session_id.

Search priority:
1. If user_uuid is provided, search by user_uuid first
2. If not found and session_id is provided, search by session_id (handles guest->login transition)
3. If only session_id is provided, search by session_id with no user_uuid

# `find_product_by_slug_map`

Finds an existing product by any slug in the provided slug map.

Searches through each slug value in the map to find a matching product.
Returns the first product found, or nil if no match.

## Examples

    iex> find_product_by_slug_map(%{"en-US" => "planter"})
    %Product{} | nil

    iex> find_product_by_slug_map(%{"en-US" => "planter", "es-ES" => "maceta"})
    %Product{} | nil  # Finds by first matching slug

# `format_product_price`

Formats the product price for catalog display.

Returns:
- "$19.99" for products without price-affecting options
- "From $19.99" if options have different price modifiers
- "$19.99 - $38.00" for range display

# `get_available_shipping_methods`

Gets available shipping methods for a cart.
Filters by weight, subtotal, and country.

# `get_cart`

Gets a cart by ID or UUID with items preloaded.

# `get_cart!`

Gets a cart by ID or UUID, raises if not found.

# `get_category`

Gets a category by ID or UUID.

# `get_category!`

Gets a category by ID or UUID, raises if not found.

# `get_category_by_any_slug`

Finds a category by slug in any language.

## Examples

    iex> Shop.get_category_by_any_slug("jarrones-macetas")
    {:ok, %Category{}, "es"}

# `get_category_by_slug`

Gets a category by slug.

Supports localized slugs stored as JSONB maps.

## Options

  - `:language` - Language code for slug lookup (default: system default)
  - `:preload` - Associations to preload

## Examples

    iex> get_category_by_slug("planters")
    %Category{}

    iex> get_category_by_slug("kashpo", language: "ru")
    %Category{}

# `get_category_by_slug_localized`

Gets a category by slug with language awareness.

Searches both translated slugs and canonical slug for the specified language.

## Parameters

  - `slug` - The URL slug to search for
  - `language` - Language code (e.g., "es-ES" or base code "en")
  - `opts` - Options: `:preload`, `:status`

## Examples

    iex> Shop.get_category_by_slug_localized("jarrones-macetas", "es-ES")
    {:ok, %Category{}}

# `get_category_slug`

Gets the localized slug for a category.

## Examples

    iex> Shop.get_category_slug(category, "es-ES")
    "jarrones-macetas"

# `get_config`

Returns the current shop configuration.

# `get_dashboard_stats`

Returns dashboard statistics for the shop.

# `get_default_currency`

Gets the default currency struct from Billing module.

# `get_default_currency_code`

Gets the default currency code from Billing module.
Falls back to "USD" if Billing has no default currency configured.

# `get_default_import_config`

Gets the default import config, if one exists.

# `get_default_language`

```elixir
@spec get_default_language() :: String.t()
```

Gets the default language code (base code, e.g., "en").

Reads from Languages module configuration or falls back to "en".

# `get_enabled_storefront_filters`

Returns only enabled storefront filters, sorted by position.

# `get_import_config`

Gets an import config by ID.

# `get_import_config!`

Gets an import config by ID, raises if not found.

# `get_import_config_by_name`

Gets an import config by name.

# `get_import_log`

Gets an import log by ID.

# `get_import_log!`

Gets an import log by ID, raises if not found.

# `get_or_create_cart`

Gets or creates a cart for the current user/session.

## Options
- `:user_uuid` - User UUID (for authenticated users)
- `:session_id` - Session ID (for guests)

# `get_price_affecting_specs`

Gets price-affecting options for a product.

Convenience wrapper around `Options.get_price_affecting_specs_for_product/1`.

# `get_price_range`

Gets the price range for a product based on option modifiers.

Returns `{min_price, max_price}` where:
- min_price = minimum possible price (base + min modifiers)
- max_price = maximum possible price (base + max modifiers)

## Example

    # Product with base $20, material options (0, +5, +10), finish options (0%, +20%)
    get_price_range(product)
    # => {Decimal.new("20.00"), Decimal.new("36.00")}

# `get_product`

Gets a product by ID or UUID.

# `get_product!`

Gets a product by ID or UUID, raises if not found.

# `get_product_by_any_slug`

Finds a product by slug in any language.

Searches across all translated slugs to find the product.
Useful for cross-language redirect when user visits with a slug
from a different language.

## Examples

    iex> Shop.get_product_by_any_slug("maceta-geometrica")
    {:ok, %Product{}, "es"}

    iex> Shop.get_product_by_any_slug("nonexistent")
    {:error, :not_found}

# `get_product_by_slug`

Gets a product by slug.

Supports localized slugs stored as JSONB maps.

## Options

  - `:language` - Language code for slug lookup (default: system default)
  - `:preload` - Associations to preload

## Examples

    iex> get_product_by_slug("planter")
    %Product{}

    iex> get_product_by_slug("kashpo", language: "ru")
    %Product{}

# `get_product_by_slug_localized`

Gets a product by slug with language awareness.

Searches both translated slugs and canonical slug for the specified language.

## Parameters

  - `slug` - The URL slug to search for
  - `language` - Language code (e.g., "es-ES" or base code "en")
  - `opts` - Options: `:preload`, `:status`

## Examples

    iex> Shop.get_product_by_slug_localized("maceta-geometrica", "es-ES")
    {:ok, %Product{}}

    iex> Shop.get_product_by_slug_localized("geometric-planter", "en")
    {:ok, %Product{}}

# `get_product_slug`

Gets the localized slug for a product.

Returns translated slug if available, otherwise canonical slug.

## Examples

    iex> Shop.get_product_slug(product, "es-ES")
    "maceta-geometrica"

# `get_selectable_specs`

Gets all selectable options for a product (for UI display).

Returns all select/multiselect options regardless of whether they affect price.
This includes options like Color that may not have price modifiers but should
still be selectable in the UI.

Convenience wrapper around `Options.get_selectable_specs_for_product/1`.

# `get_shipping_method`

Gets a shipping method by ID or UUID.

# `get_shipping_method!`

Gets a shipping method by ID or UUID, raises if not found.

# `get_shipping_method_by_slug`

Gets a shipping method by slug.

# `get_storefront_filters`

Gets storefront filter configuration from shop_config.

Returns a list of filter definition maps with keys:
key, type, label, enabled, position.

Default: price filter only.

# `list_active_categories`

Lists active categories only (for storefront display).

# `list_carts_with_count`

Lists carts with filters for admin.

# `list_categories`

Lists all categories.

## Options
- `:parent_uuid` - Filter by parent UUID (nil for root categories)
- `:status` - Filter by status: "active", "hidden", "archived", or list of statuses
- `:search` - Search in name
- `:preload` - Associations to preload

# `list_categories_localized`

Lists categories with translated fields for a specific language.

## Parameters

  - `language` - Language code for translations
  - `opts` - Standard list options

## Examples

    iex> Shop.list_categories_localized("es-ES", status: "active")
    [%Category{localized: %{name: "Jarrones...", ...}}, ...]

# `list_categories_with_count`

Lists categories with count for pagination.

# `list_category_product_options`

Returns a list of {name, id} tuples for products in a category that have images.
Used for the featured product dropdown in the admin category form.

# `list_empty_categories`

Lists categories that have no products assigned.

# `list_import_configs`

Lists all active import configs.

# `list_import_logs`

Lists recent import logs.

# `list_menu_categories`

Lists categories visible in storefront navigation/menu.
Only active categories appear in menus.
Semantic alias for list_active_categories/1.

# `list_products`

Lists all products with optional filters.

## Options
- `:status` - Filter by status (draft, active, archived)
- `:product_type` - Filter by type (physical, digital)
- `:category_uuid` - Filter by category
- `:search` - Search in title and description
- `:page` - Page number
- `:per_page` - Items per page
- `:preload` - Associations to preload

# `list_products_by_ids`

Lists products by their IDs.

Returns products in the order of the provided IDs.

# `list_products_localized`

Lists products with translated fields for a specific language.

Returns products with an additional `:localized` virtual map containing
translated fields with fallback to defaults.

## Parameters

  - `language` - Language code for translations
  - `opts` - Standard list options: `:page`, `:per_page`, `:status`, `:category_uuid`, etc.

## Examples

    iex> Shop.list_products_localized("es-ES", status: "active")
    [%Product{localized: %{title: "Maceta...", ...}}, ...]

# `list_products_with_count`

Lists products with count for pagination.

# `list_root_categories`

Lists root categories (no parent).

# `list_shipping_methods`

Lists all shipping methods.

## Options
- `:active` - Filter by active status
- `:country` - Filter by country availability

# `list_visible_categories`

Lists categories whose products are visible in storefront.
Includes both active and unlisted categories.
Use for product filtering, not for navigation menus.

# `mark_abandoned_carts`

Marks abandoned carts (no activity for X days).

# `merge_guest_cart`

Merges guest cart into user cart after login.
Accepts a user struct or user_uuid (string).

# `merge_localized_attrs`

Merges localized fields from new attributes into existing product.

Preserves existing translations while adding new ones from attrs.
Non-localized fields are replaced entirely.

## Examples

    iex> merge_localized_attrs(%Product{title: %{"en-US" => "Old"}}, %{title: %{"es-ES" => "Nuevo"}})
    %{title: %{"en-US" => "Old", "es-ES" => "Nuevo"}}

# `product_counts_by_category`

Returns a map of category_uuid => product_count for all categories.

# `product_slug_exists?`

Checks if a product slug exists for a language.

Useful for validation during translation editing.

## Examples

    iex> Shop.product_slug_exists?("maceta-geometrica", "es-ES")
    true

    iex> Shop.product_slug_exists?("maceta-geometrica", "es-ES", exclude_uuid: "some-uuid")
    false

# `product_url`

```elixir
@spec product_url(PhoenixKitEcommerce.Product.t(), String.t()) :: String.t()
```

Generates a localized URL for a product.

Returns the correct locale-prefixed URL with translated slug.
The URL respects the PhoenixKit URL prefix configuration.

## Parameters

  - `product` - The Product struct
  - `language` - Language code (e.g., "en-US", "ru", "es-ES")

## Examples

    iex> Shop.product_url(product, "es-ES")
    "/es/shop/product/maceta-geometrica"

    iex> Shop.product_url(product, "ru")
    "/ru/shop/product/geometricheskoe-kashpo"

    iex> Shop.product_url(product, "en")
    "/shop/product/geometric-planter"  # Default language - no prefix

# `remove_from_cart`

Removes item from cart.

# `set_cart_payment_option`

Sets payment option for cart.

# `set_cart_shipping`

Sets shipping method for cart.

# `set_cart_shipping_country`

Sets the shipping country for the cart.

# `start_import`

Marks import as started.

# `translations`

Returns translation helpers module for direct access.

## Examples

    iex> Shop.translations()
    PhoenixKitEcommerce.Translations

# `update_cart_item`

Updates item quantity in cart.

# `update_category`

Updates a category.

# `update_category_translation`

Updates translation for a specific language on a category.

## Parameters

  - `category` - The category struct
  - `language` - Language code (e.g., "es-ES")
  - `attrs` - Translation attributes: name, slug, description

## Examples

    iex> Shop.update_category_translation(category, "es-ES", %{
    ...>   "name" => "Jarrones y Macetas",
    ...>   "slug" => "jarrones-macetas"
    ...> })
    {:ok, %Category{}}

# `update_import_config`

Updates an import config.

# `update_import_log`

Updates an import log.

# `update_import_progress`

Updates import progress.

# `update_product`

Updates a product.

Automatically normalizes metadata (price modifiers, option values)
before saving to ensure consistent storage format.

# `update_product_translation`

Updates translation for a specific language on a product.

## Parameters

  - `product` - The product struct
  - `language` - Language code (e.g., "es-ES")
  - `attrs` - Translation attributes: title, slug, description, body_html, seo_title, seo_description

## Examples

    iex> Shop.update_product_translation(product, "es-ES", %{
    ...>   "title" => "Maceta Geométrica",
    ...>   "slug" => "maceta-geometrica"
    ...> })
    {:ok, %Product{}}

# `update_shipping_method`

Updates a shipping method.

# `update_storefront_filters`

Saves storefront filter configuration.

# `upsert_product`

Creates or updates a product by slug.

Uses explicit find-or-create pattern with proper localized field merging.
After V47 migration, slug is a JSONB map (e.g., %{"en-US" => "my-slug"}),
so ON CONFLICT doesn't work correctly - this function handles the lookup manually.

Returns {:ok, product, action} where action is :inserted or :updated.

## Parameters

  - `attrs` - Product attributes including localized fields as maps

## Examples

    # Create new product
    iex> upsert_product(%{title: %{"en-US" => "Planter"}, slug: %{"en-US" => "planter"}, price: 10})
    {:ok, %Product{}, :inserted}

    # Update existing product (found by slug)
    iex> upsert_product(%{title: %{"en-US" => "Planter V2"}, slug: %{"en-US" => "planter"}, price: 15})
    {:ok, %Product{}, :updated}

    # Add translation to existing product
    iex> upsert_product(%{title: %{"es-ES" => "Maceta"}, slug: %{"es-ES" => "maceta", "en-US" => "planter"}, price: 10})
    {:ok, %Product{title: %{"en-US" => "Planter", "es-ES" => "Maceta"}}, :updated}

# `validate_selected_specs`

Validates selected_specs against product's option schema.

Checks:
- All spec keys exist in the option schema
- All spec values are in allowed values list (if defined)
- All required options have values

## Returns

- `:ok` - All specs are valid
- `{:error, :unknown_option_key, key}` - Key not in schema
- `{:error, :invalid_option_value, %{key: key, value: value, allowed: list}}` - Value not allowed
- `{:error, :missing_required_option, key}` - Required option not provided

## Examples

    iex> validate_selected_specs(product, %{"material" => "PETG"})
    :ok

    iex> validate_selected_specs(product, %{"material" => "Unobtainium"})
    {:error, :invalid_option_value, %{key: "material", value: "Unobtainium", allowed: ["PLA", "PETG"]}}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
