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.
Summary
Functions
Adds item to cart.
Aggregates filter values for sidebar display.
PhoenixKitAI translation adapters (duck-typed discovery — see
PhoenixKitAI.Translatables).
Auto-selects payment option if only one is available.
Auto-selects the cheapest available shipping method for a cart.
Bulk delete categories.
Returns count of deleted categories. Nullifies category references on orphaned products.
0 while the catalogue source is active — see bulk_update_product_status/2.
Bulk delete products.
Returns count of deleted products. 0 while the catalogue source is
active — see bulk_update_product_status/2.
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.
0 while the catalogue source is active — see bulk_update_product_status/2.
Bulk update category status.
Returns count of updated categories. 0 while the catalogue source is
active — see bulk_update_product_status/2.
Bulk update product category.
Returns count of updated products. 0 while the catalogue source is
active — see bulk_update_product_status/2.
Bulk update product status.
Returns count of updated products. 0 while the catalogue source is
active — these ids are catalogue item uuids, not
phoenix_kit_shop_products rows, so this would silently update
nothing anyway; returning 0 up front makes that explicit rather
than reporting a phantom no-op success.
Calculates the final price for a product based on selected specifications.
Whether a cart's frozen exchange rate (§4.4, §12.2) has drifted past
fx_rate_drift_alert_pct/0 away from the currency table's CURRENT
rate — and by how much, for the checkout notice. A cart's own prices
are NEVER silently recalculated (§4.4); this only reports the drift so
checkout can offer refresh_cart_rate/1 as an explicit choice.
Whether any line in the cart needs physical shipping.
Returns just the session_id of a cart, without loading items.
Generates a localized URL for the cart page.
Generates a localized URL for the shop catalog.
Catalogue item/category form "extension slot" modules this package
contributes (duck-typed discovery — see
PhoenixKitEcommerce.Catalogue.Extension and its moduledoc). Unconditional,
like ai_translatables/0: catalogue's own discovery is responsible for
checking the module is actually usable (loaded, enabled?/0) before
calling it — phoenix_kit_catalogue is an optional dependency and this
function must not fail to compile without it.
Returns the image URL for a category, regardless of which
ProductSource adapter produced it.
Returns categories as options for select input. Returns list of {localized_name, id} tuples.
Checks if a category slug exists for a language.
Generates a localized URL for a category.
Returns a changeset for category form.
Returns a changeset for tracking import config changes.
Returns a changeset for product form.
Returns a changeset for shipping method form.
Generates a localized URL for the checkout page.
Atomically claims a one-shot boolean flag on the cart's metadata.
Clears all items from cart.
Clears the cart's shipping selection and recalculates totals.
Collects all storage file UUIDs associated with a single product.
Collects all storage file UUIDs for a list of product UUIDs.
Marks import as completed.
Converts a cart to a Billing.Order.
Counts active carts.
Creates a new cart.
Creates a new category.
Creates an import config.
Creates a new import log entry.
Creates a new product.
Creates a new shipping method.
Tailwind source roots contributed to the host's CSS build.
Resolves the currency a PERSISTED record was denominated in.
Returns the default storefront filter configuration.
Deletes a category.
Deletes an import config.
Deletes an import log.
Deletes a product.
Deletes a shipping method.
Disables the shop system.
Discovers filterable option keys from product metadata.
Enables the shop system.
Checks if the shop system is enabled.
Whether adding a product whose currency is a known, foreign code (not the shop's base) is refused rather than logged-and-continued.
Creates the legacy default import config if no configs exist.
Ensures a category has a featured_product_uuid set.
Ensures a default Prom.ua import config exists. Creates one if no config with name "prom_ua_default" is found.
Expires old guest carts.
Marks import as failed.
Finds active cart by user_uuid or session_id.
Finds an existing product by any slug in the provided slug map.
Formats the product price for catalog display.
The percentage drift above which checkout flags a cart's frozen
exchange rate as stale (§4.4) — the shop-configurable threshold behind
cart_rate_drift/1. Defaults to 5 (five percent) when the setting is
unset or fails to parse as a number.
Gets available shipping methods for a cart. Filters by weight, subtotal, and country.
The BASE currency struct (Э1-E6, §4.5) - the same value
get_default_currency/0 returns, through Billing's CACHED
get_base_currency/0 (§13) rather than a fresh query every call.
Gets a cart by ID or UUID with items preloaded.
Gets a cart by ID or UUID, raises if not found.
Gets a category by ID or UUID.
Gets a category by ID or UUID, raises if not found.
Finds a category by slug in any language.
Gets a category by slug.
Gets a category by slug with language awareness.
Gets the localized slug for a category.
Returns the current shop configuration.
Gets a raw config value stored by key in phoenix_kit_shop_config.
Returns dashboard statistics for the shop.
Gets the default currency struct from Billing module.
The base currency code from Billing, or nil when no default currency
is configured. create_cart/1 then fails loudly on its own changeset
(Cart.changeset/2 requires :currency) instead of a silent literal
masking an empty currency table (§4.2, §7.3).
Gets the default import config, if one exists.
Gets the default language code (base code, e.g., "en").
The CODE of the currency to show and charge the current shopper (§4.2,
§12.4) — the request-scoped display currency resolved through Billing's
own fail-safe (§6.3: disabled/unknown/non-positive-rate falls back to
base), or nil when no currency is configured at all.
Returns only enabled storefront filters, sorted by position.
Gets an import config by ID.
Gets an import config by ID, raises if not found.
Gets an import config by name.
Gets an import log by ID.
Gets an import log by ID, raises if not found.
Gets or creates a cart for the current user/session.
Gets price-affecting options for a product.
Gets the price range for a product based on option modifiers.
Returns the {min, max} price across active products, optionally
scoped to a category.
Gets a product by ID or UUID.
Gets a product by ID or UUID, raises if not found.
Finds a product by slug in any language.
Gets a product by slug.
Gets a product by slug with language awareness.
Gets the localized slug for a product.
Gets all selectable options for a product (for UI display).
Gets a shipping method by ID or UUID.
Gets a shipping method by ID or UUID, raises if not found.
Gets a shipping method by slug.
Gets storefront filter configuration from shop_config.
Lists active categories only (for storefront display).
Lists carts with filters for admin.
Lists all categories.
Lists categories with translated fields for a specific language.
Lists categories with count for pagination.
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.
Lists categories that have no products assigned.
Lists all active import configs.
Lists recent import logs.
Lists categories visible in storefront navigation/menu. Only active categories appear in menus. Semantic alias for list_active_categories/1.
Lists all products with optional filters.
Lists products by their IDs.
Lists products with translated fields for a specific language.
Lists products with count for pagination.
Lists root categories (no parent).
Lists all shipping methods.
Lists categories whose products are visible in storefront. Includes both active and unlisted categories. Use for product filtering, not for navigation menus.
Marks abandoned carts (no activity for X days).
Merges guest cart into user cart after login. Accepts a user struct or user_uuid (string).
Merges import attributes into an existing product.
Merges built-in default filters missing from a saved filter config.
Merges a category's storefront_filters overrides onto the global
filter list.
Notification types this module contributes (duck-typed, discovered by
core's Notifications.Types).
Whether operators asked to be notified about the given storefront event.
Applies the checkout billing country to the cart and recalculates totals, WITHOUT converting anything.
Returns a map of category_uuid => product_count for all categories.
Checks if a product slug exists for a language.
Generates a localized URL for a product.
Re-freezes a cart's exchange rate at the currency table's CURRENT rate
and re-snapshots every line at that new rate (§4.4) — the ONLY path
that changes a non-empty cart's exchange_rate. A plain add/update
never touches it (§12.2, snapshot_unit_price/2); this is the
shopper's own EXPLICIT choice, offered by the checkout drift notice
(cart_rate_drift/1) and never triggered automatically.
Moves a live guest cart from one shop-session id to another.
Removes item from cart.
Reprices the catalog after a base-currency change (spec §4.9, steps 2-4).
Whether any cart exists for this shop session id.
Sets payment option for cart.
Sets shipping method for cart.
Sets the shipping country for the cart.
Where the buyer picks a shipping method: on the cart page (legacy) or as a checkout step after billing, when the destination country is known.
Effective shipping-skip mode for checkout.
Whether this cart may convert without a shipping method, and why.
Marks import as started.
Returns translation helpers module for direct access.
Updates item quantity in cart.
Updates a category.
Updates translation for a specific language on a category.
Updates an import config.
Updates an import log.
Updates import progress.
Updates a product.
Updates translation for a specific language on a product.
Updates a shipping method.
Saves storefront filter configuration.
Creates or updates a product by slug.
Validates selected_specs against product's option schema.
Functions
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"})
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
PhoenixKitAI translation adapters (duck-typed discovery — see
PhoenixKitAI.Translatables).
Empty under the catalogue product source: PhoenixKitEcommerce.AITranslatable
translates phoenix_kit_shop_products rows, which the catalogue source
never writes to — translation moves to catalogue's own item/category AI
adapters there (design spec §5 Блок 3 / Блок 6), so this package must stop
advertising a translatable resource nothing reads through it anymore.
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-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.
Returns count of deleted categories. Nullifies category references on orphaned products.
0 while the catalogue source is active — see bulk_update_product_status/2.
Bulk delete products.
Returns count of deleted products. 0 while the catalogue source is
active — see bulk_update_product_status/2.
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.
0 while the catalogue source is active — see bulk_update_product_status/2.
Bulk update category status.
Returns count of updated categories. 0 while the catalogue source is
active — see bulk_update_product_status/2.
Bulk update product category.
Returns count of updated products. 0 while the catalogue source is
active — see bulk_update_product_status/2.
Bulk update product status.
Returns count of updated products. 0 while the catalogue source is
active — these ids are catalogue item uuids, not
phoenix_kit_shop_products rows, so this would silently update
nothing anyway; returning 0 up front makes that explicit rather
than reporting a phantom no-op success.
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
@spec cart_rate_drift(PhoenixKitEcommerce.Cart.t()) :: %{frozen: Decimal.t(), current: Decimal.t(), pct: Decimal.t()} | nil
Whether a cart's frozen exchange rate (§4.4, §12.2) has drifted past
fx_rate_drift_alert_pct/0 away from the currency table's CURRENT
rate — and by how much, for the checkout notice. A cart's own prices
are NEVER silently recalculated (§4.4); this only reports the drift so
checkout can offer refresh_cart_rate/1 as an explicit choice.
Returns nil — nothing to report — for:
- a cart already in its base currency (nothing was ever converted);
- a cart with no frozen rate at all (
exchange_rate: nil— nothing to compare against); - a cart currency the table no longer knows, that has since been
disabled, or whose current rate is not positive. Read DIRECTLY via
Billing.get_currency_by_code/1— never throughresolve_display_currency/1— because that function's §6.3 fail-safe silently substitutes the BASE currency for exactly these cases, which would make an unrelated currency's outage look like a rate drift on every open cart in it; - a missing base currency (nothing configured to compare against);
- drift at or under the threshold.
Otherwise returns %{frozen:, current:, pct:}: the cart's frozen
rate, the currency table's live effective rate, and the absolute
percentage drift between them (rounded to 2 decimal places).
Whether any line in the cart needs physical shipping.
Reads the "requires_shipping" flag snapshotted onto each cart item at
add time. Rows created before the snapshot existed fall back to the live
product's flag (batched, one query); a line whose product is GONE counts
as requiring shipping — the conservative default, since charging shipping
on a digital line is a smaller failure than shipping-free physical goods.
Digital-only carts skip the shipping-method requirement, the shipping charge, and the shipping line on the resulting order.
Returns just the session_id of a cart, without loading items.
Used to authorize order confirmation pages for orders placed BEFORE
metadata["session_id"] was recorded on the order itself. Those orders
still carry metadata["cart_uuid"], and the cart row survives
conversion (mark_cart_converted/2 flips its status, it is never
deleted) with its session_id intact — so the placing session is still
recoverable for them.
Deliberately a narrow select rather than get_cart/1: this runs on a
page load purely to compare one string, and get_cart/1 preloads items
and the shipping method.
Generates a localized URL for the cart page.
Examples
iex> Shop.cart_url("ru")
"/ru/cart"
iex> Shop.cart_url("en")
"/cart"
Generates a localized URL for the shop catalog.
Examples
iex> Shop.catalog_url("es-ES")
"/es/shop"
iex> Shop.catalog_url("en")
"/shop"
Catalogue item/category form "extension slot" modules this package
contributes (duck-typed discovery — see
PhoenixKitEcommerce.Catalogue.Extension and its moduledoc). Unconditional,
like ai_translatables/0: catalogue's own discovery is responsible for
checking the module is actually usable (loaded, enabled?/0) before
calling it — phoenix_kit_catalogue is an optional dependency and this
function must not fail to compile without it.
Returns the image URL for a category, regardless of which
ProductSource adapter produced it.
Delegates to Category.get_image_url/2 today; kept as a facade seam
because the catalogue adapter's view-struct categories won't have a
:featured_product Ecto preload to fall back on.
Returns categories as options for select input. Returns list of {localized_name, id} tuples.
Checks if a category slug exists for a language.
Examples
iex> Shop.category_slug_exists?("jarrones-macetas", "es-ES")
true
@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 structlanguage- 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
Returns a changeset for category form.
Returns a changeset for tracking import config changes.
Returns a changeset for product form.
Returns a changeset for shipping method form.
Generates a localized URL for the checkout page.
Examples
iex> Shop.checkout_url("ru")
"/ru/checkout"
iex> Shop.checkout_url("en")
"/checkout"
Atomically claims a one-shot boolean flag on the cart's metadata.
Returns true exactly once per (cart, flag) — the caller that wins the
claim; false for everyone after (or on any error). Used to deduplicate
per-cart notifications under concurrent tabs.
Clears all items from cart.
Clears the cart's shipping selection and recalculates totals.
Used when the selected method stops being eligible for the cart as it is NOW (weight change, last physical line removed) — leaving it selected showed a zero-cost method the cart had outgrown and let checkout proceed to an inevitable conversion failure.
Collects all storage file UUIDs associated with a single product.
Collects all storage file UUIDs for a list of product UUIDs.
Marks import as completed.
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 = niluntil 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
Counts active carts.
Creates a new cart.
The cart's currency is the request's display currency (§4.1, §4.4 of
the per-domain-currency spec) — the host app maps the request's domain
to it via PhoenixKitBilling.Currency.put_request_currency/1, resolved
fail-safe by get_display_currency/0 (§6.3). With no mapping in play
this is the base currency, same as before this feature existed
(§2.12). base_currency/exchange_rate freeze the base and the rate
at THIS moment (§12.2) — no code anywhere may re-read them from the
currency table for this cart after creation; only emptying the cart
refreshes them (recalculate_cart_totals!/1).
Creates a new category.
Refuses while the catalogue source is active — same reason
create_product/1 does.
Creates an import config.
Creates a new import log entry.
Creates a new product.
Automatically normalizes metadata (price modifiers, option values) before saving to ensure consistent storage format.
Refuses while the catalogue source is active — a legacy row created here would never be surfaced by a catalogue-backed storefront read.
Creates a new shipping method.
Tailwind source roots contributed to the host's CSS build.
Both README.md and AGENTS.md claimed this was implemented; it was
not, and the use PhoenixKit.Module default returns []. The
consequence was invisible rather than loud: core's
:phoenix_kit_css_sources compiler collects this from every discovered
module and writes assets/css/_phoenix_kit_sources.css, so with shop
contributing nothing, Tailwind purged every class used only by this
module's storefront and admin templates from the host build.
It stayed hidden because the compiler only warns when the TOTAL source list is empty — any other installed module masked the absence.
Resolves the currency a PERSISTED record was denominated in.
Order and cart pages used to load today's default currency, so changing
the shop currency silently relabeled every historical order's amounts.
Given the code stored on the record, this returns its Currency struct;
an unresolvable code returns nil — callers then show the bare code rather
than borrowing today's default symbol for an amount it does not describe.
Returns the default storefront filter configuration.
Deletes a category.
Refuses a view-struct (__meta__.state == :built) for the same
reason update_category/2 does.
Deletes an import config.
Deletes an import log.
Deletes a product.
Refuses a view-struct (__meta__.state == :built) for the same
reason update_product/2 does.
Deletes a shipping method.
Disables the shop system.
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.
Enables the shop system.
Checks if the shop system is enabled.
@spec enforce_product_currency?() :: boolean()
Whether adding a product whose currency is a known, foreign code (not the shop's base) is refused rather than logged-and-continued.
Defaults to false — refusing by default would brick every catalog
that still carries a stale default from before currency hygiene. A
settings-layer error degrades to false so a cache miss cannot abort
the add-to-cart transaction.
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.
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.
A view-struct (__meta__.state == :built) is returned unchanged — this
function computes a value to write, and a view-struct has nowhere to
write it (update_category/2 refuses it); read-only here means
read-only, not "raise".
Ensures a default Prom.ua import config exists. Creates one if no config with name "prom_ua_default" is found.
Expires old guest carts.
Marks import as failed.
Finds active cart by user_uuid or session_id.
Search priority:
- If user_uuid is provided, search by user_uuid first
- If not found and session_id is provided, search by session_id (handles guest->login transition)
- If only session_id is provided, search by session_id with no user_uuid
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
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
@spec fx_rate_drift_alert_pct() :: Decimal.t()
The percentage drift above which checkout flags a cart's frozen
exchange rate as stale (§4.4) — the shop-configurable threshold behind
cart_rate_drift/1. Defaults to 5 (five percent) when the setting is
unset or fails to parse as a number.
Gets available shipping methods for a cart. Filters by weight, subtotal, and country.
The BASE currency struct (Э1-E6, §4.5) - the same value
get_default_currency/0 returns, through Billing's CACHED
get_base_currency/0 (§13) rather than a fresh query every call.
Admin authoring screens (a product's own price, a shipping method's
price/thresholds) are always denominated in base, never the visitor's
display currency - this is the name to reach for there, so the call
site says what it means instead of relying on get_default_currency/0
happening to be the same value today.
Gets a cart by ID or UUID with items preloaded.
Gets a cart by ID or UUID, raises if not found.
Gets a category by ID or UUID.
Gets a category by ID or UUID, raises if not found.
Finds a category by slug in any language.
Examples
iex> Shop.get_category_by_any_slug("jarrones-macetas")
{:ok, %Category{}, "es"}
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{}
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 forlanguage- 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{}}
Gets the localized slug for a category.
Examples
iex> Shop.get_category_slug(category, "es-ES")
"jarrones-macetas"
Returns the current shop configuration.
Gets a raw config value stored by key in phoenix_kit_shop_config.
Returns nil when the key has never been set, except for the few keys
default_config_value/1 gives a typed default instead — currently
used by PhoenixKitEcommerce.ProductSource.current/0 to read
"shop_product_source" and by
PhoenixKitEcommerce.Shopify.CollectionSync.run/1 to read
"shopify_collections_filter". Distinct from get_config/0 (the
fixed dashboard-stats map above).
Returns dashboard statistics for the shop.
Gets the default currency struct from Billing module.
The base currency code from Billing, or nil when no default currency
is configured. create_cart/1 then fails loudly on its own changeset
(Cart.changeset/2 requires :currency) instead of a silent literal
masking an empty currency table (§4.2, §7.3).
Gets the default import config, if one exists.
@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".
The CODE of the currency to show and charge the current shopper (§4.2,
§12.4) — the request-scoped display currency resolved through Billing's
own fail-safe (§6.3: disabled/unknown/non-positive-rate falls back to
base), or nil when no currency is configured at all.
Storefront LiveViews assign this (not get_default_currency/0, which is
always the BASE currency) so PriceDisplay/format_price convert live
base amounts to what the visitor's domain is mapped to.
Returns only enabled storefront filters, sorted by position.
category (a %Category{}, or nil for the global list unmodified)
applies its storefront_filters overrides on top of the global config
first — see merge_storefront_filters/2. language (default nil)
additionally translates each attribute_set/metadata_option
filter's "label" to that language's attribute-set display name —
see maybe_translate_filter_labels/2.
Gets an import config by ID.
Gets an import config by ID, raises if not found.
Gets an import config by name.
Gets an import log by ID.
Gets an import log by ID, raises if not found.
Gets or creates a cart for the current user/session.
Options
:user_uuid- User UUID (for authenticated users):session_id- Session ID (for guests)
Gets price-affecting options for a product.
Convenience wrapper around Options.get_price_affecting_specs_for_product/1.
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")}
Returns the {min, max} price across active products, optionally
scoped to a category.
Options
:category_uuid- Scope to a specific category by UUID
Gets a product by ID or UUID.
Gets a product by ID or UUID, raises if not found.
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}
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{}
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 forlanguage- 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{}}
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"
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.
Gets a shipping method by ID or UUID.
Gets a shipping method by ID or UUID, raises if not found.
Gets a shipping method by slug.
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.
Lists active categories only (for storefront display).
Lists carts with filters for admin.
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
Lists categories with translated fields for a specific language.
Parameters
language- Language code for translationsopts- Standard list options
Examples
iex> Shop.list_categories_localized("es-ES", status: "active")
[%Category{localized: %{name: "Jarrones...", ...}}, ...]
Lists categories with count for pagination.
Reads through list_categories/1 (the same ProductSource.current/0
dispatch every other category read goes through) rather than querying
Category directly — under the catalogue source, listing here and
row resolution (get_category!/2) must come from the same adapter, or
a listed row's uuid resolves to nothing on the next click.
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.
Lists categories that have no products assigned.
Lists all active import configs.
Lists recent import logs.
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
Lists products by their IDs.
Returns products in the order of the provided IDs.
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 translationsopts- Standard list options::page,:per_page,:status,:category_uuid, etc.
Examples
iex> Shop.list_products_localized("es-ES", status: "active")
[%Product{localized: %{title: "Maceta...", ...}}, ...]
Lists products with count for pagination.
Lists root categories (no parent).
Lists all shipping methods.
Options
:active- Filter by active status:country- Filter by country availability
Lists categories whose products are visible in storefront. Includes both active and unlisted categories. Use for product filtering, not for navigation menus.
Marks abandoned carts (no activity for X days).
Merges guest cart into user cart after login. Accepts a user struct or user_uuid (string).
Merges import attributes into an existing product.
A feed states what it knows; it cannot state what it does not know. The importers always emit the full attribute set, so a column the file omits arrives as a blank — and treating that blank as an instruction meant a routine second import DELETED data the file never mentioned:
- a blank Body (HTML) cell erased that product's description in every language, not merely the imported one;
- a feed with no image columns cleared the product's images and vendor;
metadatawas rebuilt from the feed, dropping the admin's option price modifiers, image mappings, price-display unit and custom keys.
So a blank incoming value leaves the stored one alone, localized maps
merge per language, and metadata merges per key. Anything the feed does
carry still wins — including a value that clears a single translation.
Examples
iex> merge_localized_attrs(%Product{title: %{"en-US" => "Old"}}, %{title: %{"es-ES" => "Nuevo"}})
%{title: %{"en-US" => "Old", "es-ES" => "Nuevo"}}
Merges built-in default filters missing from a saved filter config.
Configs saved before a built-in filter existed (e.g. search) never
gain it on their own; this merges the absent built-ins in as disabled
so admins can discover and enable them from the settings page without
changing storefront behavior until they do.
Missing filters are positioned below every saved filter's "position"
(preserving their relative order from default_storefront_filters/0)
rather than reusing their default position outright — a saved config
predating the new filter can already hold that same position number, and
a tie would fall back to list order once enabled, silently overriding
the intended placement (e.g. search no longer sorting first).
Merges a category's storefront_filters overrides onto the global
filter list.
category_filters is a map of filter key => override attrs — the
shape stored at Category.storefront_filters (and, for the catalogue
source, data["ecommerce"]["storefront_filters"]). For a key that
matches a global filter, only "enabled", "position", "label" and
"set_slug" are taken from the override; every other attribute
(notably "type") keeps the global filter's value. A key absent from
the global list is appended as a brand-new filter, taken from the
override attrs as they are (with "key" set to the map key, in case
the override itself omits it).
Notification types this module contributes (duck-typed, discovered by
core's Notifications.Types).
Two audiences, deliberately separate sub-types so a shop operator can mute the order firehose without silencing their own receipts — and so a customer's confirmation is never governed by an admin-facing preference.
⚠️ The actions registered here are the NOTIFY actions. The audit trail
uses different action strings on purpose: Activity.log/1 auto-derives
notifications from registered actions, so an audit row written with a
notify action would deliver a second, duplicate notification.
Whether operators asked to be notified about the given storefront event.
@spec preview_checkout_totals(PhoenixKitEcommerce.Cart.t(), keyword()) :: {:ok, PhoenixKitEcommerce.Cart.t()} | {:error, term()}
Applies the checkout billing country to the cart and recalculates totals, WITHOUT converting anything.
The review step must show the amount the customer is about to be charged.
The cart page deliberately leaves shipping_country nil ("set at
checkout"), and tax is zero for a nil-country cart — so before this
existed, a customer reviewed a pre-tax total and convert_cart_to_order/2
then applied the country, recalculated, and charged more. Cart 100.00 +
shipping 10.00 at 20%: review said 110.00, the order said 130.00.
That gap opened the moment tax started working at all, so it ships with the same change. Runs the identical country resolution and recalculation the conversion uses, so the two cannot disagree.
Takes the same :billing_profile_uuid / :billing_data options as
convert_cart_to_order/2. Returns the reloaded cart.
Returns a map of category_uuid => product_count for all categories.
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
@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 structlanguage- 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
@spec refresh_cart_rate(PhoenixKitEcommerce.Cart.t()) :: {:ok, PhoenixKitEcommerce.Cart.t()} | {:error, :currency_unavailable | :no_base_price}
Re-freezes a cart's exchange rate at the currency table's CURRENT rate
and re-snapshots every line at that new rate (§4.4) — the ONLY path
that changes a non-empty cart's exchange_rate. A plain add/update
never touches it (§12.2, snapshot_unit_price/2); this is the
shopper's own EXPLICIT choice, offered by the checkout drift notice
(cart_rate_drift/1) and never triggered automatically.
Each line's unit_price is re-derived from its base_unit_price
through the same snapshot_unit_price/2 an add-to-cart uses
(§12.1/§12.2), so a refreshed line is indistinguishable from one added
fresh at the new rate. compare_at_price is NOT a base amount like
base_unit_price — add-to-cart freezes it forward into the cart's
currency at the SAME time as unit_price (§4.3.1) — so repricing it
must first invert it back to base with to_base/2 against the OLD
(pre-reprice) cart, then reconvert with snapshot_unit_price/2 at the
new rate — the same to-base-and-back shape calculate_shipping/3 uses
for a base-currency threshold, just without the from_base/2 half
(that inverse belongs to snapshot_unit_price/2 here, since a line's
target IS the cart's own currency). Skipping the inversion and
reconverting the already-converted figure directly would
double-convert it.
Runs in one transaction: {:error, :no_base_price} (rolled back, no
partial reprice) if any line predates base_unit_price and has
nothing to re-derive from. {:error, :currency_unavailable} — checked
up front, same fail-safe boundary as cart_rate_drift/1 — if the
cart's currency is no longer usable (unknown, disabled, or a
non-positive rate).
Moves a live guest cart from one shop-session id to another.
Used by the pre-signing cookie migration. The plug mints a FRESH id and re-keys the cart onto it, rather than re-signing whatever the client presented — re-signing a replayed value would have turned it into a fully trusted session on the very next request, which is the capability the signing exists to prevent.
Scoped to active, unclaimed carts for the same reason session_has_cart?/1
is: a converted or account-owned cart must never be re-keyed by an
unsigned cookie. Returns :ok when exactly one cart moved.
Removes item from cart.
@spec reprice_for_base_change(String.t(), String.t(), Decimal.t()) :: {:ok, %{ products: non_neg_integer(), shipping_methods: non_neg_integer(), global_modifiers: non_neg_integer(), category_modifiers: non_neg_integer(), product_modifiers: non_neg_integer() }} | {:error, term()}
Reprices the catalog after a base-currency change (spec §4.9, steps 2-4).
This is the :reprice callback PhoenixKitBilling.change_base_currency/2
invokes — see that function's moduledoc for the full contract. In short:
it runs strictly INSIDE billing's own transaction, AFTER every currency
rate has already been renormalized and BEFORE the new base is promoted,
so it must NEVER open a transaction of its own — every repo() call it
makes joins the caller's transaction automatically (both packages resolve
PhoenixKit.RepoHelper.repo() to the same host repo). Returning
{:error, _} here rolls back the ENTIRE base-currency change, including
the rate renormalization — the catalog and the currency table can never
end up disagreeing about which currency is base.
multiplier is the new base currency's PRE-operation exchange rate
(billing hands it over explicitly because it stops being derivable from
the currency table the moment renormalization has run). Every stored
FIXED authoring amount is multiplied by it and rounded to
new_base_code's decimal_places — nothing else. This is arithmetic on
stored prices, not a display conversion: Currency.present/3 and
rounding_rule (§5) never enter here (§4.9's third consequence).
Touches, regardless of a product's own currency:
products.price,.compare_at_price,.cost_per_item,.currency(set tonew_base_code— §4.6: the field means "the currency the stored price is in")- the global option schema's and every category's option schema's
FIXED
price_modifiersentries (percent entries are currency-free and are left untouched) - a product's own
metadata["_price_modifiers"]overrides whose EFFECTIVE type — its own explicit override type if given, else the schema option'smodifier_type— is fixed shipping_methods.price,.free_above_amount,.min_order_amount,.max_order_amount
GUARDED HAZARD: writing a product's overrides goes through
update_product/2, whose MetadataValidator.normalize_product_attrs/1
collapses an explicit %{"type" => ..., "value" => ...} override to a
bare string on ANY save, this one included — so a product whose
override type disagrees with its option schema's default would
silently have that override's type reverted, an operation where an
operator has the least reason to expect unrelated data to move. Rather
than let that happen silently, this function scans every product's
overrides BEFORE any write and refuses the ENTIRE operation with
{:error, {:ambiguous_modifier_overrides, mismatches}} — mismatches
a list of %{product_uuid:, option_key:, stored_type:, schema_type:}
— if any explicit override's own type disagrees with its option's
schema default. An explicit override whose type AGREES with the
schema default is harmless (the normalizer's collapse is lossless
there) and does not refuse. See MetadataValidator.normalize_product_attrs/1
for the underlying behavior this guards against.
Never touches carts or orders (§4.9 step 6) — they carry their own
frozen currency/exchange_rate (§4.4, §4.5), which is the entire
point of freezing them; this function does not reference either schema.
Product source scope — READ BEFORE EXTENDING
This function reprices the LEGACY PhoenixKitEcommerce.Product store
ONLY. This checkout's lib/ tree carries no PhoenixKitEcommerce.ProductSource
module and no phoenix_kit_catalogue dependency at all — that adapter
layer exists only on a separate, not-yet-merged branch — so there is
nothing else here to reprice.
A build where the catalogue product source IS present is a different
situation: silently repricing shipping and option modifiers while every
catalogue item's stored price stays in the old base currency is exactly
the silent shop-wide mispricing §4.9 exists to prevent, made worse by
looking like a working reprice because the counts come back non-zero.
To make that impossible rather than merely undocumented, this function
checks for PhoenixKitEcommerce.ProductSource at runtime (via
Code.ensure_loaded?/1 and a dynamic dispatch — no compile-time
reference to a module this branch does not have) and REFUSES with
{:error, {:unsupported_product_source, current}} when a source other
than Legacy is active, instead of silently doing a partial job.
Whoever wires the catalogue source in must EXTEND this function with an
equivalent pass over the catalogue item's stored price column and its
data["ecommerce"] fields (compare_at_price, cost_per_item,
currency, price_modifiers) — through
PhoenixKitCatalogue.Catalogue.update_item/3, not a second, parallel
reprice_for_base_change-like function. :reprice is one callback;
billing does not know or care which product source is active, and must
never have to.
Returns {:ok, %{products: n, shipping_methods: n, global_modifiers: n, category_modifiers: n, product_modifiers: n}} on success. The modifier
counts are broken out per store — rather than a single total — because
the admin confirmation screen (§4.9's first required consequence) has
to show an operator the blast radius of what they are about to commit
to, and "N modifiers" alone does not: "3 global, 12 category, 32
product" does. Each count is the number of individual FIXED
price-modifier VALUES touched in that store; percent entries are never
counted because they are never touched. Returns {:error, term} on the
first failure encountered — including {:error, {:unsupported_product_source, _}}
from the product-source check and {:error, {:ambiguous_modifier_overrides, _}}
from the pre-flight override scan, both above, both raised before any
write happens.
Whether any cart exists for this shop session id.
Used by PhoenixKitEcommerce.Web.Plugs.ShopSession to decide whether an
unsigned, pre-migration cookie names a real session worth adopting.
Existence only — no cart is loaded and nothing is authorized by this.
Sets payment option for cart.
Sets shipping method for cart.
Sets the shipping country for the cart.
Where the buyer picks a shipping method: on the cart page (legacy) or as a checkout step after billing, when the destination country is known.
Effective shipping-skip mode for checkout.
:off— shipping method required (legacy behavior):fallback— required when available; orders proceed without one when no method covers the buyer's country:always— shipping step disabled entirely
@spec shipping_skippable?(PhoenixKitEcommerce.Cart.t()) :: false | {:skip, :always} | {:skip, :no_method_for_country}
Whether this cart may convert without a shipping method, and why.
The country checked is the effective checkout country - cart.shipping_country
as of apply_checkout_shipping_country/2, which must have already run.
Returns false (never skippable) when the mode is :off, or when the
mode is :fallback but a method still covers the cart's country - that
case stays a hard requirement, not a fallback.
Marks import as started.
Returns translation helpers module for direct access.
Examples
iex> Shop.translations()
PhoenixKitEcommerce.Translations
Updates item quantity in cart.
Updates a category.
Refuses a view-struct (__meta__.state == :built, never :loaded) —
same reason update_product/2 does.
Updates translation for a specific language on a category.
Parameters
category- The category structlanguage- 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{}}
Updates an import config.
Updates an import log.
Updates import progress.
Updates a product.
Automatically normalizes metadata (price modifiers, option values) before saving to ensure consistent storage format.
Refuses a view-struct (__meta__.state == :built, never :loaded) —
the catalogue adapter hands those out for display only; nothing may
write through them into phoenix_kit_shop_products.
Updates translation for a specific language on a product.
Parameters
product- The product structlanguage- 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{}}
Updates a shipping method.
Saves storefront filter configuration.
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}
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"]}}